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/.github/coverage.svg b/.github/coverage.svg deleted file mode 100644 index 03d4ff0..0000000 --- a/.github/coverage.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - coverage - - 81 % - - \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml deleted file mode 100644 index e334463..0000000 --- a/.github/workflows/run-tests.yml +++ /dev/null @@ -1,99 +0,0 @@ -name: tests - -on: - pull_request: - branches: - - main - - 'feature/**' - push: - branches: - - main - - 'feature/**' - -jobs: - tests: - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: true - matrix: - os: [ ubuntu-latest ] - php: [ 8.4, 8.3, 8.2, 8.1 ] - laravel: [ 13.*, 12.*, 11.*, 10.*, 9.* ] - stability: [ prefer-lowest, prefer-stable ] - include: - - laravel: 13.* - testbench: 11.* - carbon: ^3.8.4 - - laravel: 12.* - testbench: 10.* - carbon: ^3.8.4 - - laravel: 11.* - testbench: ^9.2 - carbon: ^3.0 - - laravel: 10.* - testbench: 8.* - carbon: ^2.63 - - laravel: 9.* - testbench: 7.* - carbon: ^2.63 - exclude: - - laravel: 13.* - php: 8.2 - - laravel: 13.* - php: 8.1 - - laravel: 12.* - php: 8.1 - - laravel: 11.* - php: 8.1 - - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} - - services: - mysql: - image: mysql:8.0 - env: - MYSQL_USER: user - MYSQL_PASSWORD: secret - MYSQL_DATABASE: stats_test - MYSQL_ROOT_PASSWORD: secretroot - ports: - - 3306 - options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install SQLite 3 - run: | - sudo apt-get update - sudo apt-get install sqlite3 -y - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv - coverage: ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && 'xdebug' || 'none' }} - - - name: Install dependencies - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - - name: Execute tests - run: | - vendor/bin/phpunit ${{ startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' && '--coverage-clover=clover.xml' || '' }} - env: - DB_USERNAME: user - DB_PASSWORD: secret - DB_PORT: ${{ job.services.mysql.ports[3306] }} - - - name: Make code coverage badge - if: startsWith(github.event.head_commit.message, 'coverage') && matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' - uses: timkrase/phpunit-coverage-badge@v1.2.1 - with: - coverage_badge_path: .github/coverage.svg - push_badge: true - repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..89b24d3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,373 @@ +name: tests + +on: + pull_request: + push: + branches: + - main + - next-gen + workflow_dispatch: + +# The unreleased javaabu/* dependencies come from VCS repositories; the job's own +# token keeps Composer within GitHub's API limits +env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}' + +jobs: + pint: + runs-on: ubuntu-latest + + name: Pint + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}- + composer-${{ runner.os }}-${{ github.job }}- + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: vendor/bin/pint --test + + dependencies: + runs-on: ubuntu-latest + + name: Production dependencies + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}- + composer-${{ runner.os }}-${{ github.job }}- + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + # javaabu/helpers pins javaabu/settings ^1, which can't be installed next to settings v2 + - name: No javaabu/helpers or javaabu/settings ^1 + run: | + composer show --tree --no-dev | tee tree.txt + if grep -E "javaabu/helpers|javaabu/settings (\^|~)?1" tree.txt; then + echo "javaabu/helpers or javaabu/settings ^1 is in the production dependency tree" && exit 1 + fi + + js: + runs-on: ubuntu-latest + + name: React charts and pages, Blade styles + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + # The pages import stencil's React renderer from vendor/javaabu/stencil + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}- + composer-${{ runner.os }}-${{ github.job }}- + + - name: Install Composer dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + # Keep the store out of pnpm's own node_modules/.bin, where @javaabu/forms' + # prepare build can't emit portable types (Javaabu/react-forms#20). + - name: Use a pnpm store outside node_modules + run: pnpm config set store-dir ~/.pnpm-store + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: pnpm + + - name: Install npm dependencies + run: pnpm install --frozen-lockfile + + - run: pnpm lint + - run: pnpm format:check + - run: pnpm typecheck + + - name: Tests with coverage + run: pnpm test:coverage + + - name: Upload coverage + uses: codecov/codecov-action@v5 + with: + files: coverage/lcov.info + flags: js + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + - name: Check that the committed dist is up to date + run: | + pnpm build + git diff --exit-code -- dist || (echo "dist is out of date: run pnpm build and commit it." && exit 1) + + databases: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + database: [ mysql, mariadb, pgsql ] + + name: ${{ matrix.database }} + + services: + mysql: + image: mysql:8.4 + env: + MYSQL_DATABASE: stats_test + MYSQL_ROOT_PASSWORD: secret + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 + + mariadb: + image: mariadb:11 + env: + MARIADB_DATABASE: stats_test + MARIADB_ROOT_PASSWORD: secret + ports: + - 3307:3306 + options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=5 + + postgres: + image: postgres:17 + env: + POSTGRES_DB: stats_test + POSTGRES_PASSWORD: secret + ports: + - 5432:5432 + options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=5 + + 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, pdo_mysql, pdo_pgsql, sqlite, pdo_sqlite, iconv, intl + coverage: none + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}- + composer-${{ runner.os }}-${{ github.job }}- + + - name: Install dependencies + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Execute tests + run: vendor/bin/phpunit + env: + DB_CONNECTION: ${{ matrix.database }} + DB_DATABASE: stats_test + DB_HOST: 127.0.0.1 + DB_PORT: ${{ matrix.database == 'pgsql' && 5432 || (matrix.database == 'mariadb' && 3307 || 3306) }} + DB_USERNAME: ${{ matrix.database == 'pgsql' && 'postgres' || 'root' }} + DB_PASSWORD: secret + + without-optional: + runs-on: ubuntu-latest + + name: Without optional packages + + 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: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}- + composer-${{ runner.os }}-${{ github.job }}- + + - name: Install dependencies without activitylog and Inertia + run: | + composer remove --dev spatie/laravel-activitylog inertiajs/inertia-laravel --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - 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: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}- + composer-${{ runner.os }}-${{ github.job }}- + + - 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 + + strategy: + fail-fast: false + matrix: + php: [ 8.4, 8.5 ] + laravel: [ 12.*, 13.* ] + stability: [ prefer-lowest, prefer-stable ] + include: + - laravel: 12.* + testbench: 10.* + - laravel: 13.* + testbench: 11.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} + + env: + COVERAGE: ${{ matrix.php == '8.4' && matrix.laravel == '13.*' && matrix.stability == 'prefer-stable' }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv, intl + coverage: ${{ env.COVERAGE == 'true' && 'pcov' || 'none' }} + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer downloads + uses: actions/cache@v6 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}-${{ hashFiles('composer.json', 'composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ github.job }}-${{ strategy.job-index }}- + composer-${{ runner.os }}-${{ github.job }}- + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress + + - name: Execute tests + if: env.COVERAGE != 'true' + run: vendor/bin/phpunit + + - name: Execute tests with coverage + if: env.COVERAGE == 'true' + run: vendor/bin/phpunit --coverage-clover build/coverage.xml + + - name: Upload coverage to Codecov + if: env.COVERAGE == 'true' + uses: codecov/codecov-action@v5 + with: + files: build/coverage.xml + flags: php + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 8683765..0022109 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage .php-cs-fixer.cache .idea clover.xml +node_modules diff --git a/.php_cs.dist.php b/.php_cs.dist.php deleted file mode 100644 index 8648fdc..0000000 --- a/.php_cs.dist.php +++ /dev/null @@ -1,35 +0,0 @@ -in([ - __DIR__ . '/src', - __DIR__ . '/tests', - ]) - ->name('*.php') - ->notName('*.blade.php') - ->ignoreDotFiles(true) - ->ignoreVCS(true); - -return (new PhpCsFixer\Config()) - ->setRules([ - '@PSR12' => true, - 'array_syntax' => ['syntax' => 'short'], - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'no_unused_imports' => true, - 'not_operator_with_successor_space' => true, - 'trailing_comma_in_multiline' => true, - 'phpdoc_scalar' => true, - 'unary_operator_spaces' => true, - 'binary_operator_spaces' => true, - 'blank_line_before_statement' => [ - 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], - ], - 'phpdoc_single_line_var_spacing' => true, - 'phpdoc_var_without_name' => true, - 'method_argument_space' => [ - 'on_multiline' => 'ensure_fully_multiline', - 'keep_multiple_spaces_after_comma' => true, - ], - 'single_trait_insert_per_statement' => true, - ]) - ->setFinder($finder); diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..92bdd84 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,16 @@ +dist +coverage +node_modules +vendor +build +pnpm-lock.yaml +*.php +*.md +tests/js/fixtures +composer.json +composer.lock +docs/**/*.json +lang +stubs +.github +pint.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..6c1772b --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "printWidth": 120, + "tabWidth": 2 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d5e496c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# 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()`. +- 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). +- 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) +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 8869e77..84a34eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ 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. +- The npm packages for the React pages and charts are declared for javaabu/manifest's pages scope, so `manifest:check` doesn't ask Blade apps for them. +- 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 + +- 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 e99fa6b..96ce92a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,52 @@ # Stats -[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/stats.svg?style=flat-square)](https://packagist.org/packages/javaabu/stats) -[![Test Status](../../actions/workflows/run-tests.yml/badge.svg)](../../actions/workflows/run-tests.yml) -![Code Coverage Badge](./.github/coverage.svg) -[![Total Downloads](https://img.shields.io/packagist/dt/javaabu/stats.svg?style=flat-square)](https://packagist.org/packages/javaabu/stats) +[![Tests](https://github.com/n4ff4h/stats/actions/workflows/tests.yml/badge.svg?branch=next-gen)](https://github.com/n4ff4h/stats/actions/workflows/tests.yml?query=branch%3Anext-gen) +[![codecov](https://codecov.io/gh/n4ff4h/stats/branch/next-gen/graph/badge.svg)](https://codecov.io/gh/n4ff4h/stats/tree/next-gen) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/javaabu/stats.svg)](https://packagist.org/packages/javaabu/stats) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.md) + +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']); +``` -## Introduction -Simplifies stats generation for Laravel +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. @@ -19,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 @@ -41,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/composer.json b/composer.json index 422a094..16e6622 100644 --- a/composer.json +++ b/composer.json @@ -1,37 +1,88 @@ { "name": "javaabu/stats", - "description": "Simplifies stats generation for Laravel", + "description": "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", "homepage": "https://github.com/Javaabu/stats", "license": "MIT", "keywords": [ "javaabu", "laravel", - "stats" + "stats", + "charts", + "dashboard", + "inertia", + "dhivehi" ], "authors": [ { "name": "Arushad Ahmed", "email": "arushad@javaabu.com", "role": "Developer" + }, + { + "name": "Javaabu Pvt. Ltd.", + "email": "info@javaabu.com", + "homepage": "https://javaabu.com", + "role": "Developer" + } + ], + "repositories": [ + { + "type": "vcs", + "url": "https://github.com/Javaabu/filters", + "no-api": true + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/forms", + "no-api": true + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/manifest", + "no-api": true + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/permissions", + "no-api": true + }, + { + "type": "vcs", + "url": "https://github.com/Javaabu/stencil", + "no-api": true } ], "require": { - "php": "^8.1", - "illuminate/support": "^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", - "maatwebsite/excel": "^3.1", - "javaabu/forms": "^1.68", - "javaabu/settings": "^1.8", - "javaabu/generator-helpers": "^1.3" + "php": "^8.4", + "illuminate/cache": "^12.0 || ^13.0", + "illuminate/console": "^12.0 || ^13.0", + "illuminate/contracts": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/http": "^12.0 || ^13.0", + "illuminate/queue": "^12.0 || ^13.0", + "illuminate/routing": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "illuminate/validation": "^12.0 || ^13.0", + "illuminate/view": "^12.0 || ^13.0", + "javaabu/filters": "^1.0@dev", + "javaabu/forms": "^2.0@dev", + "javaabu/generator-helpers": "^1.3", + "javaabu/manifest": "^1.0@dev", + "javaabu/permissions": "^3.0@dev", + "javaabu/stencil": "^1.0@dev", + "nesbot/carbon": "^3.8.4" }, "require-dev": { - "laravel/pint": "^1.14", - "orchestra/testbench": "^7.0 || ^8.0 || ^9.2 || ^10.0 || ^11.0", - "phpunit/phpunit": "^9.5 || ^10.5 || ^11.5.3 || ^12.5.8 || ^13.0.3", - "spatie/laravel-activitylog": "^4.8", - "plannr/laravel-fast-refresh-database": "^1.2" + "inertiajs/inertia-laravel": "^2.0", + "javaabu/boost-javaabu-guidelines": "^1.4", + "laravel/pint": "^1.24", + "orchestra/testbench": "^10.0 || ^11.0", + "phpunit/phpunit": "^11.5.3 || ^12.5.8", + "spatie/laravel-activitylog": "^4.10" }, "suggest": { - "spatie/laravel-activitylog": "Required for included activity log based stats" + "inertiajs/inertia-laravel": "Renders the stats pages with Inertia React when stats.views is inertia (^2.0)", + "spatie/laravel-activitylog": "Needed by the built-in user logins stat (^4.10)" }, "autoload": { "psr-4": { @@ -43,16 +94,28 @@ "Javaabu\\Stats\\Tests\\": "tests/" } }, + "scripts": { + "test": "vendor/bin/phpunit", + "test-coverage": "vendor/bin/phpunit --coverage-clover build/coverage.xml", + "lint": "vendor/bin/pint --test", + "format": "vendor/bin/pint" + }, "extra": { "branch-alias": { - "dev-main": "1.0-dev" + "dev-next-gen": "2.x-dev" }, "laravel": { "providers": [ "Javaabu\\Stats\\StatsServiceProvider" - ] + ], + "aliases": { + "Stats": "Javaabu\\Stats\\Facades\\Stats" + } } }, + "config": { + "sort-packages": true + }, "minimum-stability": "dev", "prefer-stable": true } diff --git a/config/stats.php b/config/stats.php index 9b599ce..ae883e5 100644 --- a/config/stats.php +++ b/config/stats.php @@ -1,41 +1,66 @@ class. You can also register them from a + | service provider with TimeSeriesStats::register() and + | CategoricalStats::register(). | */ - 'week_starts_on_sunday' => true, + 'time_series_stats' => [ + // + ], + 'categorical_stats' => [ + // + ], /* |-------------------------------------------------------------------------- - | The locale used for dates + | Built-in stats |-------------------------------------------------------------------------- | - | The locale used to format dates + | Registers user_signups (and user_logins, with spatie/laravel-activitylog) + | for user_model, or for the model of the default guard's user provider + | when user_model is null. | */ - 'date_locale' => 'en_GB', + 'default_stats' => true, + + 'user_model' => null, /* |-------------------------------------------------------------------------- - | Date formats for different time modes + | Dates |-------------------------------------------------------------------------- | - | Used to format the date when generating stats in different time series modes - | Uses momentJS compatible formats + | week_starts_on_sunday: true for Sunday, false for Monday. + | date_locale: the locale used to work out weeks. + | date_formats: how each step is shown, as Carbon isoFormat (moment.js) formats. | */ + 'week_starts_on_sunday' => true, + + 'date_locale' => 'en_GB', + 'date_formats' => [ 'hour' => 'D MMM YY hh:mm A', 'day' => 'D MMM YY', @@ -46,97 +71,96 @@ /* |-------------------------------------------------------------------------- - | Default time series mode + | Defaults |-------------------------------------------------------------------------- | - | Default mode to show on the stats form + | What the stats pages and charts use when nothing else is chosen. | */ - 'default_time_series_mode' => \Javaabu\Stats\Enums\TimeSeriesModes::DAY, + 'default_time_series_mode' => TimeSeriesModes::DAY, - /* - |-------------------------------------------------------------------------- - | Default categorical mode - |-------------------------------------------------------------------------- - */ + 'default_categorical_mode' => CategoricalModes::NON_EMPTY, - 'default_categorical_mode' => \Javaabu\Stats\Enums\CategoricalModes::NON_EMPTY, - - /* - |-------------------------------------------------------------------------- - | Categorical items per page - |-------------------------------------------------------------------------- - | - | Default page size for the categorical provider search endpoint. - | - */ + 'default_date_range' => PresetDateRanges::LAST_7_DAYS, 'categorical_items_per_page' => 15, /* |-------------------------------------------------------------------------- - | Default date range + | Cache |-------------------------------------------------------------------------- | - | Default date range to show on stats + | Charts (StatTile, TimeSeriesChart, …) cache their data. Results of + | periods that ended before today keep for ttl.past seconds; anything that + | includes today keeps for ttl.current seconds. store null means the app's + | default cache store. Stats::forget('metric') and the InvalidatesStats + | model trait clear them. + | + | pages: whether the stats pages cache too (off: they show live data). + | warm: the queue connection and queue of the WarmStatsCache job. | */ - 'default_date_range' => \Javaabu\Stats\Enums\PresetDateRanges::LAST_7_DAYS, + 'cache' => [ + 'enabled' => env('STATS_CACHE', true), + 'store' => env('STATS_CACHE_STORE'), + 'prefix' => 'javaabu_stats', + 'ttl' => [ + 'current' => 60 * 10, + 'past' => 60 * 60 * 24, + ], + 'pages' => false, + 'warm' => [ + 'connection' => null, + 'queue' => null, + ], + ], /* |-------------------------------------------------------------------------- - | Default CSS Framework + | Views |-------------------------------------------------------------------------- | - | This option controls the default CSS framework that will be used by the - | package when rendering views + | The pages Route::stats() renders: blade (javaabu/forms components and + | SVG charts) or inertia (React pages with Onu's charts, shipped through + | javaabu/manifest). The Blade layout the pages extend, and the section + | they fill. | - | Supported: "material-admin-26" + | rtl_locales: locales whose Blade charts run right to left. | */ - 'framework' => 'material-admin-26', + 'views' => env('STATS_VIEWS', 'blade'), - /* - |-------------------------------------------------------------------------- - | Default Layout - |-------------------------------------------------------------------------- - | - | Default layout view for stats views - | - */ + 'layout' => 'layouts.app', + + 'layout_section' => 'content', - 'default_layout' => 'layouts.admin', + 'rtl_locales' => ['dv', 'ar', 'fa', 'he', 'ur'], /* |-------------------------------------------------------------------------- - | Scripts Stack + | Controllers and authorization |-------------------------------------------------------------------------- | - | The name of the stack to push scripts + | Replace a controller with a subclass, or pass `controllers` to + | Route::stats() / Route::statsApi(). The policy here is the package + | default; apps can also override it with config('permissions.policies') + | or Gate::policy(). Turn off register_permissions to register your own. | */ - 'scripts_stack' => 'scripts', - - /* - |-------------------------------------------------------------------------- - | View for time series stats - |-------------------------------------------------------------------------- - | - | Used in the main time series stats controller - | - */ + 'controllers' => [ + 'time_series' => TimeSeriesStatsController::class, + 'categorical' => CategoricalStatsController::class, + 'time_series_api' => TimeSeriesStatsApiController::class, + 'categorical_api' => CategoricalStatsApiController::class, + 'categories' => CategoryProviderApiController::class, + ], - 'time_series_stats_view' => 'stats::material-admin-26.time-series-stats.index', + 'policy' => StatsPolicy::class, - /* - |-------------------------------------------------------------------------- - | View for categorical stats - |-------------------------------------------------------------------------- - */ + 'register_permissions' => true, - 'categorical_stats_view' => 'stats::material-admin-26.categorical-stats.index', ]; diff --git a/dist/.vite/manifest.json b/dist/.vite/manifest.json new file mode 100644 index 0000000..a4c06f6 --- /dev/null +++ b/dist/.vite/manifest.json @@ -0,0 +1,11 @@ +{ + "resources/css/charts.css": { + "file": "assets/charts-B-a9Wmv-.css", + "name": "charts", + "names": [ + "charts.css" + ], + "src": "resources/css/charts.css", + "isEntry": true + } +} \ No newline at end of file diff --git a/dist/assets/charts-B-a9Wmv-.css b/dist/assets/charts-B-a9Wmv-.css new file mode 100644 index 0000000..bc038b6 --- /dev/null +++ b/dist/assets/charts-B-a9Wmv-.css @@ -0,0 +1 @@ +.stats-chart,.stats-tile,.stats-sparkline,.stats-tiles,.stats-page{--stats-foreground:var(--foreground,#0a0a0a);--stats-muted:var(--muted-foreground,#737373);--stats-border:var(--border,#e5e5e5);--stats-card:var(--card,#fff);--stats-card-foreground:var(--card-foreground,var(--stats-foreground));--stats-radius:var(--radius,.625rem);--stats-positive:var(--success,#16a34a);--stats-negative:var(--destructive,#dc2626);--stats-previous:var(--muted-foreground,#a3a3a3)}.stats-sr-only{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;block-size:1px;inline-size:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.stats-chart{inline-size:100%;min-inline-size:0;color:var(--stats-foreground);flex-direction:column;gap:.75rem;margin:0;display:flex}.stats-chart__header{gap:.125rem;display:grid}.stats-chart__title{font-weight:500;line-height:1.375}.stats-chart__description,.stats-chart__empty,.stats-page__empty{color:var(--stats-muted);font-size:.875rem}.stats-chart__totals{flex-wrap:wrap;align-items:flex-end;gap:.5rem 1.5rem;display:flex}.stats-chart__total{gap:.125rem;display:grid}.stats-chart__total-label{color:var(--stats-muted);font-size:.75rem}.stats-chart__total-value{font-variant-numeric:tabular-nums;font-size:1.5rem;font-weight:600;line-height:1}.stats-chart__total--previous .stats-chart__total-value{color:var(--stats-muted);font-size:1.125rem;font-weight:400}.stats-chart__body{min-inline-size:0}.stats-chart__svg{block-size:auto;inline-size:100%;display:block;overflow:visible}.stats-chart__grid line{stroke:var(--stats-border);stroke-width:1px}.stats-chart__zero{stroke:var(--stats-muted)!important}.stats-chart__tick{fill:var(--stats-muted);font-variant-numeric:tabular-nums;font-size:11px}.stats-chart__line{fill:none;stroke:var(--stats-series);stroke-width:2px;stroke-linejoin:round;stroke-linecap:round}.stats-chart__area{fill:var(--stats-series);fill-opacity:.2;stroke:none}.stats-chart__bar{fill:var(--stats-series)}.stats-chart__series[data-previous] .stats-chart__bar{fill-opacity:.45}.stats-chart__point{fill:var(--stats-card);stroke:var(--stats-series);stroke-width:2px}.stats-chart__point[data-hidden]{opacity:0}.stats-chart__point:hover,.stats-chart__bar:hover{opacity:.85}.stats-chart__legend{color:var(--stats-muted);flex-wrap:wrap;justify-content:center;gap:.25rem 1rem;margin:.5rem 0 0;padding:0;font-size:.75rem;list-style:none;display:flex}.stats-chart__legend li{align-items:center;gap:.375rem;display:inline-flex}.stats-chart__swatch{background:var(--stats-series);border-radius:2px;block-size:.625rem;inline-size:.625rem;display:inline-block}.stats-chart__swatch[data-dashed]{border-block-start:2px dashed var(--stats-series);background:0 0;block-size:0}.stats-chart__details summary{cursor:pointer;color:var(--stats-muted);inline-size:-moz-fit-content;inline-size:fit-content;font-size:.875rem}.stats-chart__details summary:focus-visible{outline:2px solid var(--ring,var(--stats-foreground));outline-offset:2px}.stats-chart__table-wrapper:focus-visible{outline:2px solid var(--ring,var(--stats-foreground));outline-offset:2px}a.stats-tile:focus-visible{outline:2px solid var(--ring,var(--stats-foreground));outline-offset:2px}.stats-chart__table-wrapper{border:1px solid var(--stats-border);border-radius:var(--stats-radius);max-block-size:24rem;margin-block-start:.5rem;overflow:auto}.stats-chart__table{border-collapse:collapse;font-variant-numeric:tabular-nums;inline-size:100%;font-size:.875rem}.stats-chart__table th,.stats-chart__table td{text-align:start;border-block-end:1px solid var(--stats-border);padding:.5rem .75rem}.stats-chart__table thead th{background:var(--muted,#f5f5f5);font-weight:500;position:sticky;top:0}.stats-chart__table tbody th{font-weight:400}.stats-chart__table .stats-chart__number{text-align:end}.stats-chart__body--pie{flex-wrap:wrap;justify-content:center;align-items:center;gap:1rem 2rem;display:flex}.stats-chart__pie{inline-size:min(100%,15rem);position:relative}.stats-chart__slice{fill:var(--stats-series);stroke:var(--stats-card);stroke-width:2px}.stats-chart__center{pointer-events:none;flex-direction:column;justify-content:center;align-items:center;display:flex;position:absolute;top:0;bottom:0;left:0;right:0}.stats-chart__center-value{font-variant-numeric:tabular-nums;font-size:1.25rem;font-weight:600}.stats-chart__center-label{color:var(--stats-muted);font-size:.75rem}.stats-chart__legend--pie{flex-direction:column;align-items:stretch;font-size:.875rem}.stats-chart__legend--pie li{grid-template-columns:auto 1fr auto;gap:.5rem;display:grid}.stats-chart__legend-value{color:var(--stats-foreground);font-variant-numeric:tabular-nums}.stats-delta{font-variant-numeric:tabular-nums;flex-wrap:wrap;align-items:center;gap:.25rem;font-size:.875rem;display:inline-flex}.stats-delta[data-sentiment=positive] .stats-delta__value,.stats-delta[data-sentiment=positive] .stats-delta__icon{color:var(--stats-positive)}.stats-delta[data-sentiment=negative] .stats-delta__value,.stats-delta[data-sentiment=negative] .stats-delta__icon{color:var(--stats-negative)}.stats-delta__context{color:var(--stats-muted)}.stats-sparkline{block-size:2rem;inline-size:100%;color:var(--stats-series,var(--chart-1))}.stats-sparkline svg{block-size:100%;inline-size:100%;display:block}.stats-sparkline__line{fill:none;stroke:var(--stats-series,var(--chart-1));stroke-width:1.5px;stroke-linejoin:round}.stats-sparkline__area{fill:var(--stats-series,var(--chart-1));fill-opacity:.2}.stats-tiles{grid-template-columns:repeat(auto-fit,minmax(min(100%,14rem),1fr));gap:1rem;display:grid}.stats-tiles[data-columns="2"]{grid-template-columns:repeat(auto-fit,minmax(max(min(100%,14rem),50% - .5rem),1fr))}.stats-tiles[data-columns="3"]{grid-template-columns:repeat(auto-fit,minmax(max(min(100%,14rem),33.3333% - .666667rem),1fr))}.stats-tiles[data-columns="4"]{grid-template-columns:repeat(auto-fit,minmax(max(min(100%,12rem),25% - .75rem),1fr))}.stats-tiles>*{min-inline-size:0}.stats-tile{border:1px solid var(--stats-border);border-radius:calc(var(--stats-radius) + 4px);background:var(--stats-card);color:var(--stats-card-foreground);flex-direction:column;padding:1.25rem 1.5rem;text-decoration:none;display:flex}a.stats-tile:hover{border-color:var(--stats-muted)}.stats-tile__header{justify-content:space-between;align-items:center;gap:.5rem;display:flex}.stats-tile__label{color:var(--stats-muted);font-size:.875rem;line-height:1.375rem}.stats-tile__icon{color:var(--stats-muted)}.stats-tile__value{font-variant-numeric:tabular-nums;margin-block-start:.5rem;font-size:1.875rem;line-height:1}.stats-tile .stats-delta,.stats-tile__period{margin-block-start:.25rem}.stats-tile__period{color:var(--stats-muted);font-size:.875rem}.stats-tile__sparkline{--stats-series:var(--chart-1);block-size:2.875rem;margin-block-start:.5rem}.stats-tile__footer{border-block-start:1px solid var(--stats-border);color:var(--stats-muted);margin-block-start:.75rem;padding-block-start:.5rem;font-size:.875rem}.stats-page{flex-direction:column;gap:1.5rem;display:flex}.stats-page__title{margin:0;font-size:1.5rem;font-weight:600}.stats-page__export{justify-content:flex-end;display:flex}@media (forced-colors:active){.stats-chart__area,.stats-sparkline__area{fill-opacity:1}}@media (prefers-reduced-motion:no-preference){.stats-chart__bar,.stats-chart__point{transition:opacity .15s}} 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..bd31417 --- /dev/null +++ b/docs/advanced-usage/api-reference.md @@ -0,0 +1,257 @@ +--- +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()` | | + +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 = [])`. + +## 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(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` | | + +`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, ?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 = '')` | + +# 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/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/advanced-usage/formatters.md b/docs/advanced-usage/formatters.md new file mode 100644 index 0000000..7416521 --- /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` (a `CarbonInterface`), `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 + + + + +
+ + +
+@endsection +``` + +## Inertia React + +```php +use App\Stats\Dashboard; +use Inertia\Inertia; +use Inertia\Response; +use Javaabu\Stats\Charts\Chart; + +public function __invoke(Request $request): Response +{ + abort_unless($request->user()->can('view_stats'), 403); + + return Inertia::render('Admin/Dashboard', [ + 'tiles' => array_map(fn (Chart $tile) => $tile->toArray(), Dashboard::tiles()), + 'charts' => array_map(fn (Chart $chart) => $chart->toArray(), Dashboard::charts()), + ]); +} +``` + +```tsx +// resources/js/pages/Admin/Dashboard.tsx +import { StatTiles } from "@javaabu/onu/charts" +import { StatsChart, StatsTile, type StatsChartData, type StatTileData } from "@javaabu/stats" + +interface DashboardProps { + tiles: StatTileData[] + charts: { + payments: StatsChartData + by_island: StatsChartData + by_status: StatsChartData + } +} + +export default function Dashboard({ tiles, charts }: DashboardProps) { + return ( +
+ + {tiles.map((tile) => ( + + ))} + + + + +
+ + +
+
+ ) +} +``` + +# 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..4f41cfb --- /dev/null +++ b/docs/basic-usage/time-series-stats.md @@ -0,0 +1,358 @@ +--- +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(); // CarbonInterface +$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 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 | +| --- | --- | +| `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..2f04832 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. It checks what the pages need once `@inertiajs/react` is in your `package.json`; before that, run `php artisan manifest:check --scope=pages`. + +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..1d08c1e 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. The package declares them for its pages only, so the check asks for them only when your `package.json` has an Inertia adapter (`@inertiajs/react`), or when you pass `--scope=pages`; a Blade app is never asked to install React packages for stats. 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 new file mode 100644 index 0000000..284dbce --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,173 @@ +--- +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. +- Downloads also check the policy's `export` ability, which by default allows whoever the stat's `canView()` allows. + +## 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. + +## 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 | +| --- | --- | +| `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()` | + +## 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. + +## 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. +8. Change `Carbon` type hints on your own `DateRange` classes and overridden date methods to `CarbonInterface` where PHP requires it. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c00ebba --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,19 @@ +import js from "@eslint/js" +import reactHooks from "eslint-plugin-react-hooks" +import globals from "globals" +import tseslint from "typescript-eslint" + +export default tseslint.config( + { ignores: ["dist", "coverage", "node_modules", "vendor", "build"] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["**/*.{ts,tsx,js,mjs}"], + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + plugins: { "react-hooks": reactHooks }, + rules: { + ...reactHooks.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }], + }, + }, +) 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/llms.txt b/llms.txt new file mode 100644 index 0000000..2ec5b2d --- /dev/null +++ b/llms.txt @@ -0,0 +1,49 @@ +# 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`. +- 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. + +## 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) diff --git a/package.json b/package.json new file mode 100644 index 0000000..a54db96 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "javaabu-stats-assets", + "private": true, + "description": "Build and test tooling for javaabu/stats' front end: the React charts and Inertia pages (resources/js, imported by apps as @javaabu/stats through javaabu/manifest) and the Blade chart styles (prebuilt into dist/). Apps don't install this.", + "license": "MIT", + "type": "module", + "packageManager": "pnpm@10.28.0", + "engines": { + "node": ">=20.19" + }, + "scripts": { + "build": "vite build", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "pnpm lint && pnpm format:check && pnpm typecheck && pnpm test" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@inertiajs/react": "^2.3.0", + "@javaabu/forms": "github:Javaabu/react-forms#next-gen", + "@javaabu/i18n": "github:Javaabu/i18n#build-on-git-install", + "@javaabu/onu": "^0.2.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.6.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitest/coverage-v8": "^5.1.0", + "eslint": "^10.14.0", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.14.0", + "jsdom": "^30.1.3", + "prettier": "^3.9.10", + "react": "^19.3.1", + "react-dom": "^19.3.1", + "recharts": "^3.10.1", + "typescript": "~6.0.3", + "typescript-eslint": "^8.72.1", + "vite": "^8.3.5", + "vitest": "^5.1.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@javaabu/forms", + "@javaabu/i18n" + ] + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ec3c1fa..2907de8 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -15,19 +15,14 @@ src - - - - - - - + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..a676439 --- /dev/null +++ b/pint.json @@ -0,0 +1,24 @@ +{ + "preset": "laravel", + "rules": { + "concat_space": { + "spacing": "one" + }, + "global_namespace_import": { + "import_classes": true, + "import_constants": false, + "import_functions": false + }, + "no_unused_imports": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "php_unit_method_casing": { + "case": "snake_case" + } + }, + "exclude": [ + "tests/TestSupport/stubs", + "stubs" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c2b1bd0 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5485 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.14.0) + '@inertiajs/react': + specifier: ^2.3.0 + version: 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/forms': + specifier: github:Javaabu/react-forms#next-gen + version: https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1))(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/i18n': + specifier: github:Javaabu/i18n#build-on-git-install + version: https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1) + '@javaabu/onu': + specifier: ^0.2.1 + version: 0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1))(tailwindcss@4.3.4) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.2 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@types/node': + specifier: ^26.6.6 + version: 26.6.6 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitest/coverage-v8': + specifier: ^5.1.0 + version: 5.1.0(vitest@5.1.0) + eslint: + specifier: ^10.14.0 + version: 10.14.0 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.14.0) + globals: + specifier: ^17.14.0 + version: 17.14.0 + jsdom: + specifier: ^30.1.3 + version: 30.1.3 + prettier: + specifier: ^3.9.10 + version: 3.9.10 + react: + specifier: ^19.3.1 + version: 19.3.1 + react-dom: + specifier: ^19.3.1 + version: 19.3.1(react@19.3.1) + recharts: + specifier: ^3.10.1 + version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1) + typescript: + specifier: ~6.0.3 + version: 6.0.3 + typescript-eslint: + specifier: ^8.72.1 + version: 8.72.1(eslint@10.14.0)(typescript@6.0.3) + vite: + specifier: ^8.3.5 + version: 8.3.6(@types/node@26.6.6) + vitest: + specifier: ^5.1.0 + version: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@30.1.3)(vite@8.3.6(@types/node@26.6.6)) + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@7.1.9': + resolution: {integrity: sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@asamuzakjp/dom-selector@10.0.5': + resolution: {integrity: sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.13': + resolution: {integrity: sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.13': + resolution: {integrity: sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.13': + resolution: {integrity: sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.13': + resolution: {integrity: sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.13': + resolution: {integrity: sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.13': + resolution: {integrity: sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.13': + resolution: {integrity: sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.13': + resolution: {integrity: sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.13': + resolution: {integrity: sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.11': + resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@bramus/specificity@2.4.3': + resolution: {integrity: sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==} + hasBin: true + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.4.3': + resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@4.2.7': + resolution: {integrity: sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.2 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@4.0.2': + resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.16': + resolution: {integrity: sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dnd-kit/abstract@0.5.3': + resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==} + + '@dnd-kit/collision@0.5.3': + resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==} + + '@dnd-kit/dom@0.5.3': + resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==} + + '@dnd-kit/geometry@0.5.3': + resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==} + + '@dnd-kit/helpers@0.5.3': + resolution: {integrity: sha512-fErbbjwKMJEdr7e+AMsXaZ6aQY4xPYBMK+nqybB34tE6/lQDLetZHsgLDKNVePsYIZCq/7uWMtlFjqysscWg5A==} + + '@dnd-kit/react@0.5.3': + resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@dnd-kit/state@0.5.3': + resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==} + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inertiajs/core@2.3.29': + resolution: {integrity: sha512-Cx3zibnKdsmEnXpsWhwmE9i4k5NFtK+4gtceL3Kb6RwYC1J0wi5y/EesNMkpdJLsAT2h4ugUvu9Ed9T1EUl0/A==} + + '@inertiajs/react@2.3.29': + resolution: {integrity: sha512-09SWBPxv8UrJdOWtQrEp1ro6jv1qJ8yOxH8eL+na9K/wbh7k9R0ALvAO3ofY52Cwuh7HMuSPgtRzk/q8E1k0Fg==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@internationalized/date@3.12.4': + resolution: {integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==} + + '@internationalized/number@3.6.9': + resolution: {integrity: sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21': + resolution: {tarball: https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + '@inertiajs/react': ^2.0.0 || ^3.0.0 + '@javaabu/onu': ^0.2.1 + '@testing-library/react': ^16.0.0 + react: ^18.3.0 || ^19.0.0 + react-dom: ^18.3.0 || ^19.0.0 + peerDependenciesMeta: + '@inertiajs/react': + optional: true + '@javaabu/onu': + optional: true + '@testing-library/react': + optional: true + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba': + resolution: {tarball: https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba} + version: 0.0.0 + engines: {node: '>=20.19'} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + vue: ^3.3.0 + peerDependenciesMeta: + react: + optional: true + vue: + optional: true + + '@javaabu/onu@0.2.1': + resolution: {integrity: sha512-GE4takAQwNQqD9ZmQooH7KMBrLUoiR4UzJsDCGr6cLYigoLF22tjRD3CzrdVGVl+SAhz2jjwOozxzyvAyTNmxA==} + peerDependencies: + '@deck.gl/aggregation-layers': ^9.4.0 + '@deck.gl/core': ^9.4.0 + '@deck.gl/google-maps': ^9.4.0 + '@deck.gl/layers': ^9.4.0 + '@fontsource-variable/geist': ^5.3.0 + '@fontsource-variable/inter': ^5.3.0 + '@fontsource-variable/jetbrains-mono': ^5.3.0 + '@fontsource-variable/noto-sans-thaana': ^5.3.0 + '@fontsource-variable/source-serif-4': ^5.3.0 + '@fortawesome/fontawesome-svg-core': ^7.3.1 + '@fortawesome/react-fontawesome': ^3.5.0 + '@fullcalendar/core': ^6.1.21 + '@fullcalendar/daygrid': ^6.1.21 + '@fullcalendar/interaction': ^6.1.21 + '@fullcalendar/list': ^6.1.21 + '@fullcalendar/react': ^6.1.21 + '@fullcalendar/timegrid': ^6.1.21 + '@googlemaps/markerclusterer': ^2.6.2 + '@iconify/react': ^6.0.2 + '@tanstack/react-query': ^5.104.1 + '@tiptap/core': ^3.31.4 + '@tiptap/extension-image': ^3.31.4 + '@tiptap/extension-placeholder': ^3.31.4 + '@tiptap/extension-table': ^3.31.4 + '@tiptap/extension-text-align': ^3.31.4 + '@tiptap/pm': ^3.31.4 + '@tiptap/react': ^3.31.4 + '@tiptap/starter-kit': ^3.31.4 + '@tiptap/static-renderer': ^3.31.4 + '@tiptap/suggestion': ^3.31.4 + '@turf/turf': ^7.4.0 + '@vis.gl/react-google-maps': ^1.10.1 + barcode-detector: ^3.2.2 + media-chrome: ^4.19.3 + react: ^19.0.0 + react-dom: ^19.0.0 + react-easy-crop: ^6.2.4 + react-hook-form: ^7.89.0 + recharts: ^3.10.1 + shiki: ^4.5.0 + signature_pad: ^5.1.4 + tailwindcss: ^4.0.0 + terra-draw: ^1.37.0 + terra-draw-google-maps-adapter: ^1.7.0 + write-excel-file: ^4.1.1 + yet-another-react-lightbox: ^3.32.2 + peerDependenciesMeta: + '@deck.gl/aggregation-layers': + optional: true + '@deck.gl/core': + optional: true + '@deck.gl/google-maps': + optional: true + '@deck.gl/layers': + optional: true + '@fontsource-variable/geist': + optional: true + '@fontsource-variable/inter': + optional: true + '@fontsource-variable/jetbrains-mono': + optional: true + '@fontsource-variable/noto-sans-thaana': + optional: true + '@fontsource-variable/source-serif-4': + optional: true + '@fortawesome/fontawesome-svg-core': + optional: true + '@fortawesome/react-fontawesome': + optional: true + '@fullcalendar/core': + optional: true + '@fullcalendar/daygrid': + optional: true + '@fullcalendar/interaction': + optional: true + '@fullcalendar/list': + optional: true + '@fullcalendar/react': + optional: true + '@fullcalendar/timegrid': + optional: true + '@googlemaps/markerclusterer': + optional: true + '@iconify/react': + optional: true + '@tiptap/core': + optional: true + '@tiptap/extension-image': + optional: true + '@tiptap/extension-placeholder': + optional: true + '@tiptap/extension-table': + optional: true + '@tiptap/extension-text-align': + optional: true + '@tiptap/pm': + optional: true + '@tiptap/react': + optional: true + '@tiptap/starter-kit': + optional: true + '@tiptap/static-renderer': + optional: true + '@tiptap/suggestion': + optional: true + '@turf/turf': + optional: true + '@vis.gl/react-google-maps': + optional: true + barcode-detector: + optional: true + media-chrome: + optional: true + react-easy-crop: + optional: true + recharts: + optional: true + shiki: + optional: true + signature_pad: + optional: true + terra-draw: + optional: true + terra-draw-google-maps-adapter: + optional: true + write-excel-file: + optional: true + yet-another-react-lightbox: + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@oxc-project/types@0.156.0': + resolution: {integrity: sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==} + + '@preact/signals-core@1.14.5': + resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.2.0': + resolution: {integrity: sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==} + + '@radix-ui/react-accessible-icon@1.2.0': + resolution: {integrity: sha512-BB4+HHkFl3HipEAhm/mLhrpXV+2kRX7MvY4AaImUcQVeWQt5KhV1JEByNs8NGfJOcr95RcQGijt9+OrCo68xGA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.3.1': + resolution: {integrity: sha512-lCZ6sbGS/7WO0j84yx4srIgOf0MRJ/sZ13RonADXxvKBym8xrxNCq5f6DibGcr38sIlwPk8/52uYdXeOPsbEDw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.2.1': + resolution: {integrity: sha512-MWC4N7RY+qsbNrFNo7yQ9ZbdoZEW+B5DtpWxJfUnVCqcQxxqqg3qis3RMPTDbCNaTIdHJSn5XDkl9ZdVWXknLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.17': + resolution: {integrity: sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.2.0': + resolution: {integrity: sha512-gHEU+Lx8DlT/FYIjZQvvBJrFSwUngXTjh7FeL9QvJaNALDLez6pgDj2axCUihBjtPBy82LN0gNb25+sTcSXNmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.3.1': + resolution: {integrity: sha512-ssZOkfQNkFpYVmCPQlr978xSaU7moSrGXdwg4j2Xs2WlGl/1KsSZtfpK+WnLnx0Rwll1BAh6oUrAy/uR31SBvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.4.1': + resolution: {integrity: sha512-KWtwhh+98ML3seZ9vO9yRY2OwaQG90dCiDAxjpSNY2AaZ3Hik3B0dsk6m0ILywo/no3A9/SozadyPMNweVT8yw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.2.1': + resolution: {integrity: sha512-fSzLb/qy1wuZSxQBcxpW0hNXYHDMWiIXDGyJGdA4+FSLknOWZsNRrTCUavxb4PI8b8lvHo/CysO6NVi5zLpo5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.2.0': + resolution: {integrity: sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.4.1': + resolution: {integrity: sha512-Pddn3Z8lio3/ChxyXaEP3jHTTo1LTHCWwF8+mG1chQlWijP+ttVHBKwezrQTiV6Mj670ojvumqvuGh+mdv33iQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.3.0': + resolution: {integrity: sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.3.1': + resolution: {integrity: sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.2.0': + resolution: {integrity: sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.2.1': + resolution: {integrity: sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.2.1': + resolution: {integrity: sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.2.0': + resolution: {integrity: sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.3.1': + resolution: {integrity: sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.3.0': + resolution: {integrity: sha512-KnmrCM27m7Jo1R8S6cFDPrEeq20Jfn5dqde0SZrJr2lVXlj6/QZSUCyFoYh1d8ti0uUrMNK5Lx6dUDuINT+f4Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.2.1': + resolution: {integrity: sha512-4QooHxgmpCKRjj1Pft+hETKidYcrbPKcVwMGtk2Oa5DcWY2IH1fHuQt9LG6q5hUQBBV2MhQxSvtx6B/C1xfiTw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.17': + resolution: {integrity: sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.2.1': + resolution: {integrity: sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.2.1': + resolution: {integrity: sha512-bRTYtS9JITV2dUYqYZb7+N5SooQBkZrNRejfmyrSKe3Y57aXhKgx//kBUP0kY7pEpsWR/U1AHsAJRzjwCrK3PA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.4.1': + resolution: {integrity: sha512-XPPZjkeyCSMNNvt0FyDv/PUauG7Ow4GwHQ/XZSwW36F/TS7rR4HFTsXH8ewLnXTOLn3x96/pmTPNQIVoTX/86g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.2.1': + resolution: {integrity: sha512-KtHdcgnaAK6rohQ/MMJoGYcbgSRJsIuGBdHrFeTB1/9SaKlDs14oSOniW6BL6M43HqvgCO3u4WuVdFLkuc6sqw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.2.1': + resolution: {integrity: sha512-vu4+ZoJAaFjdI7l1lDSZRs+4dEv83Jmu7lyzajPrAeQzX74y84n0rUNWbWSgN9XB5NE8IY86Z02kGH6oRrzR+w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.3.1': + resolution: {integrity: sha512-ZNxvXRx5W2Wos4fXCXb5lpdqN0Z5chf+mStOUOTr4ik7d+62MRvoe9rzCA/eGNg36lxXqSrKEKmwuZEcUrIstw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.4.0': + resolution: {integrity: sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.2.0': + resolution: {integrity: sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.11': + resolution: {integrity: sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.2.0': + resolution: {integrity: sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.2.1': + resolution: {integrity: sha512-yQBiM6J3YM8Ums1orkEFpbznK47geKxa0igikzcaS1xHgdba3DdAQBsGbCTt5BVfR8zhEvgxQ0poGcINaD3lvw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.5.1': + resolution: {integrity: sha512-QbyVT1ddnjqB9H+CUYKkNWdU9fIK3QHCYtjf7mS6ekSqiuXkWtWn5UZW9mhA8qtl3vrVyIsmR9gePstAtKrqkw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.2.1': + resolution: {integrity: sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.4.0': + resolution: {integrity: sha512-CFrLqHNejWq5FhKAcj9V/WPGRsol76HBQto81NdOBVFEOqIEkJkKIJt0Z824A/9hHLCtoiR/Kw3euRgzXP2pxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.4.1': + resolution: {integrity: sha512-888ZwW0Md6GyptuqYABRX9nLKgoYfcooCyBBvVaX5Jp3e+8Jp2i4Zo/Uy50Hm+Ysn2JfloQXstW4U36w8teVtg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.2.0': + resolution: {integrity: sha512-1jUAap0wZEqVP05Ou7kJgRxe/W+Qftng+rUNWGa0qkKbv9r5pWTG3E7a3ELKo2eJoHKMECnzuYrZpSyO1YgcYQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.6.1': + resolution: {integrity: sha512-IRNRR90moPqxfwCJ0XbMJIxo70W0/1ZExT/sNCxEHMaPx0SF9HoQrcPEC75+H28cFKa0b+KH63anfLYJmwKacw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.5.0': + resolution: {integrity: sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.4.1': + resolution: {integrity: sha512-NpBGwc40rY02ZmSvuyeKk0Bvc8ZsNeTMi4qHvJ+ZH4EiYCv0zIXEgdCW4bVdzgIUeP9xxbLJSxKOUfqITYNmkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.2.1': + resolution: {integrity: sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.3.1': + resolution: {integrity: sha512-iQvL0J7XK4HtJNGkkWMyYrboNseI7vtotFeUkIno8YNxVA9oM7B6//fh0Mc6TSKI96+zioLWi98lQLso88yJmQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.2.1': + resolution: {integrity: sha512-O8Ldx5ao4p4KoW+DO8/PmkOe5mdKNi3F2SseqjVj88wvDGDHe60cUmO0Zm/uHsKrvNgQGV8Efpuh95Tht/h4vw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.2.1': + resolution: {integrity: sha512-+3MUq+WyiBplXYqQ0EIYlq8R4sLQ0T5NbOK7DMdDLCKO93pe2uE0DUJTWWfTBprpJJXxfoF3eMW0xpakVZrYsg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.2.1': + resolution: {integrity: sha512-Cm8m7JTEeUdbfA6T9xgyu8HfOpyvSx8pB/wj0DlEuWaJe9FilIs4Az9wci/lU9RbthV/UkltszMw5WnnD+m/Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.4.1': + resolution: {integrity: sha512-NxLFGSFtZ78zVfcoOhTwR1xwqo5qx1Jh8Pw6FMZ9McCaqfRzz7/gIRahs2Oy5XlPgQ+8QkUcKbN5B0U44J/kCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.5': + resolution: {integrity: sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.3.0': + resolution: {integrity: sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.6': + resolution: {integrity: sha512-9cRullnQRv+kqnRmFlmh0WC3kAPuGmHiQ9VwsXdrYk//sBhgCKEttcpPEPgIQbyx6TGUmooVEHYTyohwz/lSJg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.5': + resolution: {integrity: sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.13': + resolution: {integrity: sha512-feijiN1oPk4rck4u9Dm9tsU4mT6Wp9oeEEhaz1WUEs1VuqLf8xzsFG+Qk+uWq6TnMH9O+WRECRg0NftyP9E4Fw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@react-types/shared@3.37.0': + resolution: {integrity: sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@reduxjs/toolkit@2.14.0': + resolution: {integrity: sha512-m/PxMwRe0xLL6XUsAgy0YjInklHcfi9uMwFhJk+QINqjxqPvyc89T5cMjszA5bDmTE0gwGc7zr9LRO4gL+mhaw==} + peerDependencies: + '@types/react': '*' + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-redux: + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.16': + resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.16': + resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.16': + resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.16': + resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.16': + resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.16': + resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.16': + resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.16': + resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.16': + resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.16': + resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/slugify@3.0.1': + resolution: {integrity: sha512-N1oPvN6sytRKZCskv0L+vSyvnUYi9JplPXZ90lT6EBsijjHrUdoXttb0Lna6pw1TWMe8vlajQnLDtU9KYHqtDg==} + engines: {node: '>=20'} + + '@sindresorhus/transliterate@2.3.1': + resolution: {integrity: sha512-gVaaGtKYMYAMmI8buULVH3A2TXVJ98QiwGwI7ddrWGuGidGC2uRt4FHs22+8iROJ0QTzju9CuMjlVsrvpqsdhA==} + engines: {node: '>=20'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@swc/helpers@0.5.24': + resolution: {integrity: sha512-6tcXo4kA8HgGO7AVmawB57RYi/piBPYVHVcO6dXjX+SZw8GoHmi262sp4v2Ha2kNP9Cx84qei27yT+fjS1lxgg==} + + '@tanstack/query-core@5.106.1': + resolution: {integrity: sha512-erYv/UACddcdgjOL2ixmFdK6tO/AVBeUcTCOtX2N51yZpDQ2U7KJ+xgB/bCNfsF3uoCDTqGHEEbDiFsIO9YUmQ==} + + '@tanstack/react-query@5.106.1': + resolution: {integrity: sha512-ORDvJG/DSfmlFL9lQ8GDkE0VjtQbLeRDY7BL8xjKkQFGLfjFGfNWNuteWgPqA2gPJgLLuWfQ7IX0FlsHCJriyg==} + peerDependencies: + '@types/react': ^18 || ^19 + react: ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/react-table@9.3.1': + resolution: {integrity: sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug==} + engines: {node: '>=20'} + peerDependencies: + react: '>=18' + + '@tanstack/react-virtual@3.14.26': + resolution: {integrity: sha512-HUDlfsrYH6ko5xH56L62Mcl/uzT+AGVNMHFuDNqDJMdZWc1AQa2oeVKNUvZ/RWBZQ4TYtyQerFrNvLnp39h65A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + + '@tanstack/table-core@9.3.1': + resolution: {integrity: sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ==} + engines: {node: '>=20'} + + '@tanstack/virtual-core@3.19.11': + resolution: {integrity: sha512-/5iHb0RHBvj8Di59FDs4ESNm45jls33aZAi4zccVXZjc/6ZvwQP6nzEYeT6weXjTf9E6/vrp2JhYWRyO+ZP13A==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@7.0.1': + resolution: {integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.2.0': + resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.10': + resolution: {integrity: sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.27': + resolution: {integrity: sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==} + + '@types/node@26.6.6': + resolution: {integrity: sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@typescript-eslint/eslint-plugin@8.72.1': + resolution: {integrity: sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.72.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.72.1': + resolution: {integrity: sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.72.1': + resolution: {integrity: sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.72.1': + resolution: {integrity: sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.72.1': + resolution: {integrity: sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.72.1': + resolution: {integrity: sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.72.1': + resolution: {integrity: sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.72.1': + resolution: {integrity: sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.72.1': + resolution: {integrity: sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.72.1': + resolution: {integrity: sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/coverage-v8@5.1.0': + resolution: {integrity: sha512-OY9aShBQG5KOIw2zCo9fDoCHI9RHliNHJ//nM7m6jGg41Hyke9JOGWmeMlK74xYErsxMoK8mIB3gAeEyFVA7LA==} + peerDependencies: + '@vitest/browser': 5.1.0 + vitest: 5.1.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@2.1.0': + resolution: {integrity: sha512-WOjuS+xcjSIy7ruYBQ+9BUcV7XZQ0FyVcbWbWxW9TRx/kLGx5SQaeFRIFDJxjNplb6YsT7SpKo2zuXXUo91OIQ==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@2.1.0': + resolution: {integrity: sha512-+ZFta25nWtGCOBG9/IET8w1RO5Oawsd6H8Uf6AJ9EA/StFuKVtKAEJEJ6uRt84OH+T4YkI0ASmIHAhRA+0KVxw==} + engines: {node: '>=22'} + + '@vitest/mocker@5.1.0': + resolution: {integrity: sha512-5umB9/jlWcqVS6fsrFmv5Gdnpu6wAUio/lEOwAmvB7v+lFo/IW2B9FhAD0p19GCULTgMzLCdUmuCDCnAzWgppQ==} + peerDependencies: + msw: ^2.4.9 || ^3.0.0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.1.0': + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.7: + resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.21.0: + resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.35: + resolution: {integrity: sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + browserslist@4.29.5: + resolution: {integrity: sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001822: + resolution: {integrity: sha512-Y3fvWQWzc69dlCyr07tuNrEhotv+db5FpMSPD3slZnkPKwRNOKwFBz2dP966XUmWMXLTOt+YShkFoQoom3U7hg==} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + country-flag-icons@1.6.20: + resolution: {integrity: sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.5: + resolution: {integrity: sha512-Th8qGXoBalmWsstpkuhmBuTsBQIFqYOJtjlBvNVvBfJ8E3uYusBUwUR8HU20U0qKKQkgCvKyR1YjiozWadk5QA==} + engines: {node: '>=12'} + + d3-color@3.1.1: + resolution: {integrity: sha512-lfab8x0Bj0UO7SNsY8dZSbNIA6ipbkE3X0NG9nqjCu6CfCHZseuDnOSJY7hidKrjpP++i1M2qT+FlAdPKYRilA==} + engines: {node: '>=12'} + + d3-ease@3.0.2: + resolution: {integrity: sha512-B5znKqRmKROAN4oHWQ50W/qisVjA9GXh2WMsVKdFVp4DxWmcrOsweqgUOUVsv5th96kwaZlokU6SlV4J3gak1w==} + engines: {node: '>=12'} + + d3-format@3.1.3: + resolution: {integrity: sha512-+F+/49swqmGuQJmSKnVMIDH/QkEFV5li9QRj0xm/KhyAUBv099O5PrvpQFxBp24PYph17Mnvt9mjKQV0ii0nMA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.2: + resolution: {integrity: sha512-IRzGU62DtxhvsnSTWMQIdnoZ6gA+BCWwBon1EJI6SjoN595799b6z/QRBzXfFhE8iupUPp3042yBNo0STPqnzQ==} + engines: {node: '>=12'} + + d3-path@3.1.1: + resolution: {integrity: sha512-jsbgV8zIT38ReB9O7X7gcLndTQRONSqNNd+oGIaTBdUug4r5jFmp0N6RY9R5GWbdUDucn0qoTGyfjapT4tDhcg==} + engines: {node: '>=12'} + + d3-scale@4.0.3: + resolution: {integrity: sha512-66cWobCNzcMvlfoaDHCxuIv9TBUWG1BLOWdv9kHHFuCr1JdqAWPvTmsaEYDD5NRMyWZ5+7u0GqiCDfMXMqh3hg==} + engines: {node: '>=12'} + + d3-shape@3.2.1: + resolution: {integrity: sha512-dm2o+lbKdzg9tY7GEag/dB9hcbodLfwkE/DkvqbELJXaMz0yKzWWhZqhbI7CKubukA7HnRyz0avdtvi7EPxPvw==} + engines: {node: '>=12'} + + d3-time-format@4.1.1: + resolution: {integrity: sha512-22XaqjyCjpqgda20erhsOjGmC3vvYwTNYniKAd45W5UWLDF43moeOuV9qYpRqobIlsnK0UhnKZjUUJm3VgORUA==} + engines: {node: '>=12'} + + d3-time@3.1.1: + resolution: {integrity: sha512-Ze7rjrhMzdeulh0/hjcfFj7XLAHOfowtEMfsoG6DU866HPynQNW7Wfen6hzXcqKTv8XzxvF6Hzr5XD6bMHr2ug==} + engines: {node: '>=12'} + + d3-timer@3.0.2: + resolution: {integrity: sha512-1Il1aliroVSErrv5aulRBxAY+7+DnaoLIWGd1C6SsBTWDeobWiP/V69vwb8vT6abR1kKwaNe1196pmaQ1fK90A==} + engines: {node: '>=12'} + + data-urls@8.0.0: + resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==} + engines: {node: ^22.14.0 || >=24.0.0} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.477: + resolution: {integrity: sha512-PN++F4zWZoVDGWinxMnSCpVqKhqFOJfLXxdU8bYJKudAEsHL/XKVITOKWVbfMt1r276Mi9RDLU9VHx9uKJ4RMQ==} + + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + + entities@9.0.0: + resolution: {integrity: sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==} + engines: {node: '>=20.19.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-toolkit@1.53.1: + resolution: {integrity: sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.14.0: + resolution: {integrity: sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + follow-redirects@1.16.2: + resolution: {integrity: sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.14.0: + resolution: {integrity: sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + html-encoding-sniffer@7.0.0: + resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} + engines: {node: ^22.13.0 || >=24.0.0} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.14: + resolution: {integrity: sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==} + engines: {node: '>= 4'} + + immer@11.2.9: + resolution: {integrity: sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + + input-otp@1.5.0: + resolution: {integrity: sha512-3AcfdW1sNG0FmSA5hHMBXG7jNW5CdcdKs8ln8JOmn6S2SRkoXoJx+2UwC+ZvjflfnOdHJx0SfQ9/YjlxVYLtGQ==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@30.1.3: + resolution: {integrity: sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + laravel-precognition@1.0.2: + resolution: {integrity: sha512-0H08JDdMWONrL/N314fvsO3FATJwGGlFKGkMF3nNmizVFJaWs17816iM+sX7Rp8d5hUjYCx6WLfsehSKfaTxjg==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libphonenumber-js@1.13.15: + resolution: {integrity: sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.69.0: + resolution: {integrity: sha512-UTFECm4yEgkgMN0kfLuxWcmlk+4u02Jrqehgk3ZXRx/Ud68OpP7hjkp67bvkBHmIpMaZCIveF1QDYeupO7XmYQ==} + peerDependencies: + '@types/react': '*' + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@1.4.5: + resolution: {integrity: sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.63: + resolution: {integrity: sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parse5@8.1.0: + resolution: {integrity: sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.32: + resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.10: + resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + qs@6.17.1: + resolution: {integrity: sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==} + engines: {node: '>=0.6'} + + radix-ui@1.8.1: + resolution: {integrity: sha512-HPfpVGizO9xANlsFSauruLhRe/3+iOt3vPX0MXYle/UQi3gorQrSYtBcdiv0BZYewcl2rvL59qrIuLFMGAmUrw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + react-aria-components@1.22.1: + resolution: {integrity: sha512-UTHaYHOaM71bOCjGPF50DcLywlrwAodE63IP64LB4kO+OizatQEL8TesVEOM8+TkBeSgHoflJ45388IlJfa7aw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-aria@3.53.1: + resolution: {integrity: sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-day-picker@10.0.2: + resolution: {integrity: sha512-QetiKtZWE9Uaw/pM/SYNv6WPYOTRnSJ2puL0GgwkmUFMIwiY0dtjH1W4B7DOVXVNQM0ZOClGpH/heOfBSzWeiA==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + react-dom@19.3.1: + resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==} + peerDependencies: + react: ^19.3.1 + + react-hook-form@7.91.0: + resolution: {integrity: sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-phone-number-input@3.4.18: + resolution: {integrity: sha512-EmPhJFSxQB7CTmLAlQfGAQQPF+WmJECU08gjyUt50zd2iESb4ETsm6z+BRfUEjH+TXct4EWvJvub+OztJxb/Cg==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-redux@9.3.1: + resolution: {integrity: sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-stately@3.51.0: + resolution: {integrity: sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.3.1: + resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==} + engines: {node: '>=0.10.0'} + + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} + + rolldown@1.2.16: + resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@6.3.2: + resolution: {integrity: sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==} + hasBin: true + + semver@7.8.6: + resolution: {integrity: sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + sonner@2.0.8: + resolution: {integrity: sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tailwindcss@4.3.4: + resolution: {integrity: sha512-PCJZTFFdG+sL3gvnP1Do9o6GbkvH2q0p04xSs3f7cSvmAeK+sttG+/7ncK1fdRwKFfC7n+wDDJaOMBSefkI9ow==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinybench@6.2.3: + resolution: {integrity: sha512-Al01C2JIBnO2CXCAPbpAl6sUiOZHeThGtHYNTa51JRw9PyWOCdr+9Ong05R4qg/Sk3+H645E8ov1h2c58ityNw==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.20: + resolution: {integrity: sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==} + + tldts@7.4.19: + resolution: {integrity: sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@7.0.0: + resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==} + engines: {node: ^22.14.0 || >=24.0.0} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.9.1: + resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.72.1: + resolution: {integrity: sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@8.12.4: + resolution: {integrity: sha512-8Mx+uNKKER2bA4AdtedkqRbwUCIJFr2nJckXRe5yrDqzePqwLP7VNcctN8/XdmDSbF7mS+jJKIszlL+vbam1VA==} + engines: {node: '>=22.19.0'} + + update-browserslist-db@1.3.4: + resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + victory-vendor@37.4.1: + resolution: {integrity: sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==} + + vite@8.3.6: + resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.1.0: + resolution: {integrity: sha512-MzmNFnrwa8v8lF1bLxw3UJ4EREwVOVb75VqJZmUDRjJ6EluK/x9th7qTbaeDk41WxE1GxWqmTXcdsfobfH5sOA==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.1.0 + '@vitest/browser-preview': 5.1.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.1.0 + '@vitest/coverage-v8': 5.1.0 + '@vitest/ui': 5.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@6.0.0: + resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@17.3.0: + resolution: {integrity: sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==} + engines: {node: ^22.14.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@7.1.9': + dependencies: + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.7(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + + '@asamuzakjp/dom-selector@10.0.5': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.13': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.13 + '@babel/helper-compilation-targets': 7.29.13 + '@babel/helper-module-transforms': 7.29.13(@babel/core@7.29.13) + '@babel/helpers': 7.29.13 + '@babel/parser': 7.29.13 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.13 + '@babel/types': 7.29.11 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.2 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.13': + dependencies: + '@babel/parser': 7.29.13 + '@babel/types': 7.29.11 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.13': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.5 + lru-cache: 5.1.1 + semver: 6.3.2 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.13 + '@babel/types': 7.29.11 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.13(@babel/core@7.29.13)': + dependencies: + '@babel/core': 7.29.13 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.13 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.13': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.13': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.11 + + '@babel/parser@7.29.13': + dependencies: + '@babel/types': 7.29.11 + + '@babel/runtime@7.29.13': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.13 + '@babel/types': 7.29.11 + + '@babel/traverse@7.29.13': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.13 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.13 + '@babel/template': 7.29.7 + '@babel/types': 7.29.11 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.11': + dependencies: + '@babel/helper-string-parser': 7.29.13 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@bramus/specificity@2.4.3': + dependencies: + css-tree: 3.2.1 + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@csstools/color-helpers@6.1.2': {} + + '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-color-parser@4.2.7(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.16(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.2': {} + + '@date-fns/tz@1.5.0': {} + + '@dnd-kit/abstract@0.5.3': + dependencies: + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/collision@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/dom@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/collision': 0.5.3 + '@dnd-kit/geometry': 0.5.3 + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/geometry@0.5.3': + dependencies: + '@dnd-kit/state': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/helpers@0.5.3': + dependencies: + '@dnd-kit/abstract': 0.5.3 + tslib: 2.9.1 + + '@dnd-kit/react@0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@dnd-kit/abstract': 0.5.3 + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/state': 0.5.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + tslib: 2.9.1 + + '@dnd-kit/state@0.5.3': + dependencies: + '@preact/signals-core': 1.14.5 + tslib: 2.9.1 + + '@eslint-community/eslint-utils@4.10.1(eslint@10.14.0)': + dependencies: + eslint: 10.14.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.14.0)': + optionalDependencies: + eslint: 10.14.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@exodus/bytes@1.16.0': {} + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@floating-ui/utils@0.2.12': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inertiajs/core@2.3.29': + dependencies: + '@types/lodash-es': 4.17.12 + axios: 1.21.0 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + qs: 6.17.1 + transitivePeerDependencies: + - debug + - supports-color + + '@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@inertiajs/core': 2.3.29 + '@types/lodash-es': 4.17.12 + laravel-precognition: 1.0.2 + lodash-es: 4.18.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - debug + - supports-color + + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/number@3.6.9': + dependencies: + '@swc/helpers': 0.5.24 + + '@internationalized/string@3.2.10': + dependencies: + '@swc/helpers': 0.5.24 + + '@javaabu/forms@https://codeload.github.com/Javaabu/react-forms/tar.gz/9ba003a130a02bc5411a1e257c66a6c504a77d21(@inertiajs/react@2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(@javaabu/onu@0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1))(tailwindcss@4.3.4))(@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@javaabu/i18n': https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@inertiajs/react': 2.3.29(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@javaabu/onu': 0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1))(tailwindcss@4.3.4) + '@testing-library/react': 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + transitivePeerDependencies: + - vue + + '@javaabu/i18n@https://codeload.github.com/Javaabu/i18n/tar.gz/76730212d6f0f1d87a78635ed6e005dd5713cbba(react@19.3.1)': + optionalDependencies: + react: 19.3.1 + + '@javaabu/onu@0.2.1(@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1))(react@19.3.1)(recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1))(tailwindcss@4.3.4)': + dependencies: + '@dnd-kit/dom': 0.5.3 + '@dnd-kit/helpers': 0.5.3 + '@dnd-kit/react': 0.5.3(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@internationalized/date': 3.12.4 + '@sindresorhus/slugify': 3.0.1 + '@tanstack/react-query': 5.106.1(@types/react@19.3.0)(react@19.3.1) + '@tanstack/react-table': 9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/react-virtual': 3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + cmdk: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + date-fns: 4.4.0 + embla-carousel-react: 8.6.0(react@19.3.1) + input-otp: 1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + lucide-react: 1.69.0(@types/react@19.3.0)(react@19.3.1) + radix-ui: 1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-aria-components: 1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-day-picker: 10.0.2(@types/react@19.3.0)(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-hook-form: 7.91.0(@types/react@19.3.0)(react@19.3.1) + react-phone-number-input: 3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + sonner: 2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + tailwind-merge: 3.7.0 + tailwindcss: 4.3.4 + tw-animate-css: 1.4.0 + uqr: 0.1.3 + vaul: 1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + optionalDependencies: + recharts: 3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@oxc-project/types@0.156.0': {} + + '@preact/signals-core@1.14.5': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.2.0': {} + + '@radix-ui/react-accessible-icon@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-accordion@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-alert-dialog@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-arrow@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-aspect-ratio@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-avatar@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-checkbox@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collapsible@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-collection@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-context-menu@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-context@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-direction@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dismissable-layer@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-dropdown-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.2.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-form@0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-hover-card@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-label@2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menu@2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-menubar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-navigation-menu@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-one-time-password-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-password-toggle-field@0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popover@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-popper@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-portal@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-presence@1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-progress@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-radio-group@1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-roving-focus@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-scroll-area@1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-select@2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + aria-hidden: 1.2.6 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-separator@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slider@1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slot@1.5.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-switch@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tabs@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toast@1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle-group@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toggle@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-toolbar@1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-tooltip@1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-use-callback-ref@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.3.0(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-escape-keydown@1.1.6(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-previous@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-size@1.1.5(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-visually-hidden@1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/rect@1.1.3': {} + + '@react-types/shared@3.37.0(react@19.3.1)': + dependencies: + react: 19.3.1 + + '@reduxjs/toolkit@2.14.0(@types/react@19.3.0)(react-redux@9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1))(react@19.3.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.2.9 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.2.0 + optionalDependencies: + '@types/react': 19.3.0 + react: 19.3.1 + react-redux: 9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1) + + '@rolldown/binding-android-arm-eabi@1.2.16': + optional: true + + '@rolldown/binding-android-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.16': + optional: true + + '@rolldown/binding-darwin-x64@1.2.16': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.16': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.16': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.16': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.16': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.16': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.16': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.16': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/slugify@3.0.1': + dependencies: + '@sindresorhus/transliterate': 2.3.1 + escape-string-regexp: 5.0.0 + + '@sindresorhus/transliterate@2.3.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@swc/helpers@0.5.24': {} + + '@tanstack/query-core@5.106.1': {} + + '@tanstack/react-query@5.106.1(@types/react@19.3.0)(react@19.3.1)': + dependencies: + '@tanstack/query-core': 5.106.1 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + '@tanstack/react-store@0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + '@tanstack/react-table@9.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/react-store': 0.11.2(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@tanstack/table-core': 9.3.1 + react: 19.3.1 + transitivePeerDependencies: + - react-dom + + '@tanstack/react-virtual@3.14.26(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@tanstack/virtual-core': 3.19.11 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + '@tanstack/store@0.11.2': {} + + '@tanstack/table-core@9.3.1': + dependencies: + '@tanstack/store': 0.11.2 + + '@tanstack/virtual-core@3.19.11': {} + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.13 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.2)(vitest@5.1.0)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@30.1.3)(vite@8.3.6(@types/node@26.6.6)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + dependencies: + '@babel/runtime': 7.29.13 + '@testing-library/dom': 10.4.2 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.2.0': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.10': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.27 + + '@types/lodash@4.17.27': {} + + '@types/node@26.6.6': + dependencies: + undici-types: 8.9.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/use-sync-external-store@0.0.6': {} + + '@typescript-eslint/eslint-plugin@8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0)(typescript@6.0.3))(eslint@10.14.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.72.1(eslint@10.14.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.72.1 + '@typescript-eslint/type-utils': 8.72.1(eslint@10.14.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.72.1(eslint@10.14.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.72.1 + eslint: 10.14.0 + ignore: 7.0.14 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.72.1(eslint@10.14.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.72.1 + '@typescript-eslint/types': 8.72.1 + '@typescript-eslint/typescript-estree': 8.72.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.72.1 + debug: 4.4.3 + eslint: 10.14.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.72.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@6.0.3) + '@typescript-eslint/types': 8.72.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.72.1': + dependencies: + '@typescript-eslint/types': 8.72.1 + '@typescript-eslint/visitor-keys': 8.72.1 + + '@typescript-eslint/tsconfig-utils@8.72.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.72.1(eslint@10.14.0)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.72.1 + '@typescript-eslint/typescript-estree': 8.72.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.72.1(eslint@10.14.0)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.14.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.72.1': {} + + '@typescript-eslint/typescript-estree@8.72.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.72.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.72.1(typescript@6.0.3) + '@typescript-eslint/types': 8.72.1 + '@typescript-eslint/visitor-keys': 8.72.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.6 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.72.1(eslint@10.14.0)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.14.0) + '@typescript-eslint/scope-manager': 8.72.1 + '@typescript-eslint/types': 8.72.1 + '@typescript-eslint/typescript-estree': 8.72.1(typescript@6.0.3) + eslint: 10.14.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.72.1': + dependencies: + '@typescript-eslint/types': 8.72.1 + eslint-visitor-keys: 5.0.1 + + '@vitest/coverage-v8@5.1.0(vitest@5.1.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 2.1.0 + '@vitest/istanbul-lib-report': 2.1.0 + ast-v8-to-istanbul: 1.0.7 + magicast: 0.5.5 + obug: 3.0.0 + std-env: 4.3.0 + tinyrainbow: 3.2.0 + vitest: 5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@30.1.3)(vite@8.3.6(@types/node@26.6.6)) + + '@vitest/istanbul-lib-coverage@2.1.0': {} + + '@vitest/istanbul-lib-report@2.1.0': + dependencies: + '@vitest/istanbul-lib-coverage': 2.1.0 + + '@vitest/mocker@5.1.0(vite@8.3.6(@types/node@26.6.6))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.1.0 + estree-walker: 3.0.3 + magic-string: 1.4.5 + optionalDependencies: + vite: 8.3.6(@types/node@26.6.6) + + '@vitest/spy@5.1.0': {} + + acorn-jsx@5.3.2(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + + acorn@8.19.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.9.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.7: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + asynckit@0.4.0: {} + + axios@1.21.0: + dependencies: + follow-redirects: 1.16.2 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.35: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.29.5: + dependencies: + baseline-browser-mapping: 2.11.35 + caniuse-lite: 1.0.30001822 + electron-to-chromium: 1.5.477 + node-releases: 2.0.63 + update-browserslist-db: 1.3.4(browserslist@4.29.5) + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001822: {} + + chai@6.3.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + classnames@2.5.1: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + country-flag-icons@1.6.20: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css.escape@1.5.1: {} + + csstype@3.2.3: {} + + d3-array@3.2.5: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.1: {} + + d3-ease@3.0.2: {} + + d3-format@3.1.3: {} + + d3-interpolate@3.0.2: + dependencies: + d3-color: 3.1.1 + + d3-path@3.1.1: {} + + d3-scale@4.0.3: + dependencies: + d3-array: 3.2.5 + d3-format: 3.1.3 + d3-interpolate: 3.0.2 + d3-time: 3.1.1 + d3-time-format: 4.1.1 + + d3-shape@3.2.1: + dependencies: + d3-path: 3.1.1 + + d3-time-format@4.1.1: + dependencies: + d3-time: 3.1.1 + + d3-time@3.1.1: + dependencies: + d3-array: 3.2.5 + + d3-timer@3.0.2: {} + + data-urls@8.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + transitivePeerDependencies: + - '@noble/hashes' + + date-fns@4.4.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js-light@2.5.1: {} + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.477: {} + + embla-carousel-react@8.6.0(react@19.3.1): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.3.1 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + + entities@9.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-toolkit@1.53.1: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.14.0): + dependencies: + '@babel/core': 7.29.13 + '@babel/parser': 7.29.13 + eslint: 10.14.0 + hermes-parser: 0.25.1 + zod: 4.6.7 + zod-validation-error: 4.0.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.10 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.14.0: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.14.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.10 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.10 + + esutils@2.0.3: {} + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + follow-redirects@1.16.2: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.14.0: {} + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + html-encoding-sniffer@7.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@5.3.2: {} + + ignore@7.0.14: {} + + immer@11.2.9: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + input-format@0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + input-otp@1.5.0(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + internmap@2.0.3: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + js-tokens@10.0.0: {} + + js-tokens@4.0.0: {} + + jsdom@30.1.3: + dependencies: + '@asamuzakjp/css-color': 7.1.9 + '@asamuzakjp/dom-selector': 10.0.5 + '@bramus/specificity': 2.4.3 + '@csstools/css-syntax-patches-for-csstree': 1.1.16(css-tree@3.2.1) + '@exodus/bytes': 1.16.0 + css-tree: 3.2.1 + data-urls: 8.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 7.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + parse5: 8.1.0 + saxes: 6.0.0 + tough-cookie: 6.0.2 + undici: 8.12.4 + w3c-xmlserializer: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.3.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + jsesc@3.1.0: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + laravel-precognition@1.0.2: + dependencies: + axios: 1.21.0 + lodash-es: 4.18.1 + transitivePeerDependencies: + - debug + - supports-color + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libphonenumber-js@1.13.15: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.18.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@11.5.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.69.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + lz-string@1.5.0: {} + + magic-string@1.4.5: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.13 + '@babel/types': 7.29.11 + source-map-js: 1.2.2 + + math-intrinsics@1.1.0: {} + + mdn-data@2.27.1: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.63: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@3.0.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parse5@8.1.0: + dependencies: + entities: 9.0.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prelude-ls@1.2.1: {} + + prettier@3.9.10: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + qs@6.17.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + radix-ui@1.8.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/primitive': 1.2.0 + '@radix-ui/react-accessible-icon': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-accordion': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-alert-dialog': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-arrow': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-aspect-ratio': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-avatar': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-checkbox': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collapsible': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-collection': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-context-menu': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-direction': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-dismissable-layer': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-dropdown-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-focus-guards': 1.2.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-focus-scope': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-form': 0.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-hover-card': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-label': 2.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menu': 2.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-menubar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-navigation-menu': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-one-time-password-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-password-toggle-field': 0.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popover': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-popper': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-portal': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-primitive': 2.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-progress': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-radio-group': 1.5.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-roving-focus': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-scroll-area': 1.4.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-select': 2.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-separator': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slider': 1.6.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-slot': 1.5.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-switch': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tabs': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toast': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toggle-group': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-toolbar': 1.2.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-tooltip': 1.4.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + '@radix-ui/react-use-callback-ref': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-controllable-state': 1.3.0(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.6(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-use-size': 1.1.5(@types/react@19.3.0)(react@19.3.1) + '@radix-ui/react-visually-hidden': 1.2.13(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + react-aria-components@1.22.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + client-only: 0.0.1 + react: 19.3.1 + react-aria: 3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-aria@3.53.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-stately: 3.51.0(react@19.3.1) + use-sync-external-store: 1.7.0(react@19.3.1) + + react-day-picker@10.0.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-dom@19.3.1(react@19.3.1): + dependencies: + react: 19.3.1 + scheduler: 0.28.0 + + react-hook-form@7.91.0(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-phone-number-input@3.4.18(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.20 + input-format: 0.3.14(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + libphonenumber-js: 1.13.15 + prop-types: 15.8.1 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + + react-redux@9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + redux: 5.0.1 + + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.1) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.1) + tslib: 2.9.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.1) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + react-stately@3.51.0(react@19.3.1): + dependencies: + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.9 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.37.0(react@19.3.1) + '@swc/helpers': 0.5.24 + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + get-nonce: 1.0.1 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + react@19.3.1: {} + + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@17.0.2)(react@19.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.14.0(@types/react@19.3.0)(react-redux@9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1))(react@19.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.53.1 + eventemitter3: 5.0.4 + immer: 11.2.9 + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + react-is: 17.0.2 + react-redux: 9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1) + reselect: 5.2.0 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.7.0(react@19.3.1) + victory-vendor: 37.4.1 + transitivePeerDependencies: + - '@types/react' + - redux + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + + require-from-string@2.0.2: {} + + reselect@5.2.0: {} + + rolldown@1.2.16: + dependencies: + '@oxc-project/types': 0.156.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.16 + '@rolldown/binding-android-arm64': 1.2.16 + '@rolldown/binding-darwin-arm64': 1.2.16 + '@rolldown/binding-darwin-x64': 1.2.16 + '@rolldown/binding-freebsd-x64': 1.2.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.16 + '@rolldown/binding-linux-arm64-gnu': 1.2.16 + '@rolldown/binding-linux-arm64-musl': 1.2.16 + '@rolldown/binding-linux-ppc64-gnu': 1.2.16 + '@rolldown/binding-linux-s390x-gnu': 1.2.16 + '@rolldown/binding-linux-x64-gnu': 1.2.16 + '@rolldown/binding-linux-x64-musl': 1.2.16 + '@rolldown/binding-openharmony-arm64': 1.2.16 + '@rolldown/binding-win32-arm64-msvc': 1.2.16 + '@rolldown/binding-win32-x64-msvc': 1.2.16 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + semver@6.3.2: {} + + semver@7.8.6: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + optionalDependencies: + '@types/react': 19.3.0 + + source-map-js@1.2.2: {} + + std-env@4.3.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.4: {} + + tiny-invariant@1.3.3: {} + + tinybench@6.2.3: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.2.0: {} + + tldts-core@7.4.20: {} + + tldts@7.4.19: + dependencies: + tldts-core: 7.4.20 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.19 + + tr46@7.0.0: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.9.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.72.1(eslint@10.14.0)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.72.1(@typescript-eslint/parser@8.72.1(eslint@10.14.0)(typescript@6.0.3))(eslint@10.14.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.72.1(eslint@10.14.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.72.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.72.1(eslint@10.14.0)(typescript@6.0.3) + eslint: 10.14.0 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@8.9.0: {} + + undici@8.12.4: {} + + update-browserslist-db@1.3.4(browserslist@4.29.5): + dependencies: + browserslist: 4.29.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + uqr@0.1.3: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 19.3.1 + tslib: 2.9.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.1): + dependencies: + react: 19.3.1 + + vaul@1.1.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1): + dependencies: + '@radix-ui/react-dialog': 1.3.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + react: 19.3.1 + react-dom: 19.3.1(react@19.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + victory-vendor@37.4.1: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.2.0 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.5 + d3-ease: 3.0.2 + d3-interpolate: 3.0.2 + d3-scale: 4.0.3 + d3-shape: 3.2.1 + d3-time: 3.1.1 + d3-timer: 3.0.2 + + vite@8.3.6(@types/node@26.6.6): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.32 + rolldown: 1.2.16 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.6 + fsevents: 2.3.3 + + vitest@5.1.0(@types/node@26.6.6)(@vitest/coverage-v8@5.1.0)(jsdom@30.1.3)(vite@8.3.6(@types/node@26.6.6)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.1.0(vite@8.3.6(@types/node@26.6.6)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.5 + obug: 3.0.0 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.6(@types/node@26.6.6) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 26.6.6 + '@vitest/coverage-v8': 5.1.0(vitest@5.1.0) + jsdom: 30.1.3 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@6.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@17.3.0: + dependencies: + '@exodus/bytes': 1.16.0 + tr46: 7.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + word-wrap@1.2.5: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} 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..5c7f64c --- /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` (`php artisan manifest:check` lists them in apps with `@inertiajs/react`; Blade apps need none of them). + +## 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..448bffd --- /dev/null +++ b/resources/boost/skills/javaabu-stats/references/common-mistakes.md @@ -0,0 +1,24 @@ +# 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. +- **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 + +- 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. +- 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/resources/css/charts.css b/resources/css/charts.css new file mode 100644 index 0000000..fc5f8f1 --- /dev/null +++ b/resources/css/charts.css @@ -0,0 +1,469 @@ +/* + * javaabu/stats: the Blade charts. Every colour, radius and font comes from + * Onu's tokens (--chart-1 … --chart-8, --card, --border, --muted-foreground, …), + * so the SVG charts follow the theme, dark mode and --onu-primary like the + * React charts. Logical properties only, so the layout works right to left. + */ + +.stats-chart, +.stats-tile, +.stats-sparkline, +.stats-tiles, +.stats-page { + --stats-foreground: var(--foreground, #0a0a0a); + --stats-muted: var(--muted-foreground, #737373); + --stats-border: var(--border, #e5e5e5); + --stats-card: var(--card, #fff); + --stats-card-foreground: var(--card-foreground, var(--stats-foreground)); + --stats-radius: var(--radius, 0.625rem); + --stats-positive: var(--success, #16a34a); + --stats-negative: var(--destructive, #dc2626); + --stats-previous: var(--muted-foreground, #a3a3a3); +} + +.stats-sr-only { + position: absolute; + inline-size: 1px; + block-size: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Chart */ + +.stats-chart { + display: flex; + flex-direction: column; + gap: 0.75rem; + min-inline-size: 0; + inline-size: 100%; + margin: 0; + color: var(--stats-foreground); +} + +.stats-chart__header { + display: grid; + gap: 0.125rem; +} + +.stats-chart__title { + font-weight: 500; + line-height: 1.375; +} + +.stats-chart__description, +.stats-chart__empty, +.stats-page__empty { + font-size: 0.875rem; + color: var(--stats-muted); +} + +.stats-chart__totals { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem 1.5rem; +} + +.stats-chart__total { + display: grid; + gap: 0.125rem; +} + +.stats-chart__total-label { + font-size: 0.75rem; + color: var(--stats-muted); +} + +.stats-chart__total-value { + font-size: 1.5rem; + font-weight: 600; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.stats-chart__total--previous .stats-chart__total-value { + font-size: 1.125rem; + font-weight: 400; + color: var(--stats-muted); +} + +.stats-chart__body { + min-inline-size: 0; +} + +.stats-chart__svg { + display: block; + inline-size: 100%; + block-size: auto; + overflow: visible; +} + +.stats-chart__grid line { + stroke: var(--stats-border); + stroke-width: 1; +} + +.stats-chart__zero { + stroke: var(--stats-muted) !important; +} + +.stats-chart__tick { + fill: var(--stats-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.stats-chart__line { + fill: none; + stroke: var(--stats-series); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; +} + +.stats-chart__area { + fill: var(--stats-series); + fill-opacity: 0.2; + stroke: none; +} + +.stats-chart__bar { + fill: var(--stats-series); +} + +.stats-chart__series[data-previous] .stats-chart__bar { + fill-opacity: 0.45; +} + +.stats-chart__point { + fill: var(--stats-card); + stroke: var(--stats-series); + stroke-width: 2; +} + +.stats-chart__point[data-hidden] { + opacity: 0; +} + +.stats-chart__point:hover, +.stats-chart__bar:hover { + opacity: 0.85; +} + +.stats-chart__legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.25rem 1rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; + font-size: 0.75rem; + color: var(--stats-muted); +} + +.stats-chart__legend li { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} + +.stats-chart__swatch { + display: inline-block; + inline-size: 0.625rem; + block-size: 0.625rem; + border-radius: 2px; + background: var(--stats-series); +} + +.stats-chart__swatch[data-dashed] { + block-size: 0; + border-block-start: 2px dashed var(--stats-series); + background: none; +} + +.stats-chart__details summary { + cursor: pointer; + font-size: 0.875rem; + color: var(--stats-muted); + inline-size: fit-content; +} + +.stats-chart__details summary:focus-visible, +.stats-chart__table-wrapper:focus-visible, +a.stats-tile:focus-visible { + outline: 2px solid var(--ring, var(--stats-foreground)); + outline-offset: 2px; +} + +.stats-chart__table-wrapper { + max-block-size: 24rem; + overflow: auto; + margin-block-start: 0.5rem; + border: 1px solid var(--stats-border); + border-radius: var(--stats-radius); +} + +.stats-chart__table { + inline-size: 100%; + border-collapse: collapse; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} + +.stats-chart__table th, +.stats-chart__table td { + padding: 0.5rem 0.75rem; + text-align: start; + border-block-end: 1px solid var(--stats-border); +} + +.stats-chart__table thead th { + position: sticky; + inset-block-start: 0; + background: var(--muted, #f5f5f5); + font-weight: 500; +} + +.stats-chart__table tbody th { + font-weight: 400; +} + +.stats-chart__table .stats-chart__number { + text-align: end; +} + +/* Pie */ + +.stats-chart__body--pie { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 1rem 2rem; +} + +.stats-chart__pie { + position: relative; + inline-size: min(100%, 15rem); +} + +.stats-chart__slice { + fill: var(--stats-series); + stroke: var(--stats-card); + stroke-width: 2; +} + +.stats-chart__center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.stats-chart__center-value { + font-size: 1.25rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.stats-chart__center-label { + font-size: 0.75rem; + color: var(--stats-muted); +} + +.stats-chart__legend--pie { + flex-direction: column; + align-items: stretch; + font-size: 0.875rem; +} + +.stats-chart__legend--pie li { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.5rem; +} + +.stats-chart__legend-value { + color: var(--stats-foreground); + font-variant-numeric: tabular-nums; +} + +/* Delta */ + +.stats-delta { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; +} + +.stats-delta[data-sentiment="positive"] .stats-delta__value, +.stats-delta[data-sentiment="positive"] .stats-delta__icon { + color: var(--stats-positive); +} + +.stats-delta[data-sentiment="negative"] .stats-delta__value, +.stats-delta[data-sentiment="negative"] .stats-delta__icon { + color: var(--stats-negative); +} + +.stats-delta__context { + color: var(--stats-muted); +} + +/* Sparkline */ + +.stats-sparkline { + inline-size: 100%; + block-size: 2rem; + color: var(--stats-series, var(--chart-1)); +} + +.stats-sparkline svg { + display: block; + inline-size: 100%; + block-size: 100%; +} + +.stats-sparkline__line { + fill: none; + stroke: var(--stats-series, var(--chart-1)); + stroke-width: 1.5; + stroke-linejoin: round; +} + +.stats-sparkline__area { + fill: var(--stats-series, var(--chart-1)); + fill-opacity: 0.2; +} + +/* Stat tiles */ + +.stats-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); + gap: 1rem; +} + +.stats-tiles[data-columns="2"] { + grid-template-columns: repeat(auto-fit, minmax(max(min(100%, 14rem), calc((100% - 1rem) / 2)), 1fr)); +} + +.stats-tiles[data-columns="3"] { + grid-template-columns: repeat(auto-fit, minmax(max(min(100%, 14rem), calc((100% - 2rem) / 3)), 1fr)); +} + +.stats-tiles[data-columns="4"] { + grid-template-columns: repeat(auto-fit, minmax(max(min(100%, 12rem), calc((100% - 3rem) / 4)), 1fr)); +} + +.stats-tiles > * { + min-inline-size: 0; +} + +.stats-tile { + display: flex; + flex-direction: column; + padding: 1.25rem 1.5rem; + border: 1px solid var(--stats-border); + border-radius: calc(var(--stats-radius) + 4px); + background: var(--stats-card); + color: var(--stats-card-foreground); + text-decoration: none; +} + +a.stats-tile:hover { + border-color: var(--stats-muted); +} + +.stats-tile__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.stats-tile__label { + font-size: 0.875rem; + line-height: 1.375rem; + color: var(--stats-muted); +} + +.stats-tile__icon { + color: var(--stats-muted); +} + +.stats-tile__value { + margin-block-start: 0.5rem; + font-size: 1.875rem; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.stats-tile .stats-delta, +.stats-tile__period { + margin-block-start: 0.25rem; +} + +.stats-tile__period { + font-size: 0.875rem; + color: var(--stats-muted); +} + +.stats-tile__sparkline { + --stats-series: var(--chart-1); + margin-block-start: 0.5rem; + block-size: 2.875rem; +} + +.stats-tile__footer { + margin-block-start: 0.75rem; + padding-block-start: 0.5rem; + border-block-start: 1px solid var(--stats-border); + font-size: 0.875rem; + color: var(--stats-muted); +} + +/* Pages */ + +.stats-page { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.stats-page__title { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.stats-page__export { + display: flex; + justify-content: flex-end; +} + +@media (forced-colors: active) { + .stats-chart__area, + .stats-sparkline__area { + fill-opacity: 1; + } +} + +@media (prefers-reduced-motion: no-preference) { + .stats-chart__bar, + .stats-chart__point { + transition: opacity 150ms ease; + } +} diff --git a/resources/js/Pages/Categorical/Index.tsx b/resources/js/Pages/Categorical/Index.tsx new file mode 100644 index 0000000..52537a2 --- /dev/null +++ b/resources/js/Pages/Categorical/Index.tsx @@ -0,0 +1,7 @@ +import { StatsPage } from "../../components/StatsPage" +import type { StatsPageProps } from "../../types" + +/** stats::Categorical/Index — the categorical stats page */ +export default function Index(props: StatsPageProps) { + return +} diff --git a/resources/js/Pages/TimeSeries/Index.tsx b/resources/js/Pages/TimeSeries/Index.tsx new file mode 100644 index 0000000..916bf2e --- /dev/null +++ b/resources/js/Pages/TimeSeries/Index.tsx @@ -0,0 +1,7 @@ +import { StatsPage } from "../../components/StatsPage" +import type { StatsPageProps } from "../../types" + +/** stats::TimeSeries/Index — the time series stats page */ +export default function Index(props: StatsPageProps) { + return +} diff --git a/resources/js/components/StatsChart.tsx b/resources/js/components/StatsChart.tsx new file mode 100644 index 0000000..0ca3fbe --- /dev/null +++ b/resources/js/components/StatsChart.tsx @@ -0,0 +1,135 @@ +import { CategoryChart, PieChart, Sparkline, StatTile, TimeSeriesChart, type ChartConfig } from "@javaabu/onu/charts" +import type { ComponentProps } from "react" +import type { StatsChartData, StatsComparison, StatTileData } from "../types" + +export interface StatsChartProps { + /** A chart from PHP: `$chart->toArray()`, or an Inertia prop */ + chart: StatsChartData + className?: string +} + +function comparisonProps(comparison: StatsComparison | null) { + return comparison ? { comparison } : {} +} + +/** + * Draws a stats chart from PHP with Onu's chart components: `TimeSeriesChart`, + * `CategoryChart`, `PieChart`, `Sparkline` or `StatTile`, by the chart's type. + */ +export function StatsChart({ chart, className }: StatsChartProps) { + const common = { + id: chart.id, + className, + valueFormat: chart.valueFormat, + ...(chart.height ? { height: chart.height } : {}), + } + + switch (chart.type) { + case "time-series": + return ( + + ) + + case "category": { + const props = { + ...common, + title: chart.title, + description: chart.description ?? undefined, + data: chart.data, + categoryKey: chart.categoryKey, + categoryLabel: chart.categoryLabel ?? undefined, + config: chart.config as ChartConfig, + stacked: chart.stacked, + ...comparisonProps(chart.comparison), + } + + return chart.variant === "bar" ? ( + + ) : ( + + ) + } + + case "pie": + return ( + + ) + + case "sparkline": + return ( + + ) + + case "stat-tile": + return + } +} + +export interface StatsTileProps extends Omit, "label" | "value"> { + /** A tile from PHP: `StatTile::make(...)->toArray()` */ + tile: StatTileData +} + +/** + * Onu's `StatTile` for a tile from PHP. With `href`, the whole tile is a link. + */ +export function StatsTile({ tile, ...props }: StatsTileProps) { + const tileProps = { + label: tile.label, + value: tile.value, + valueFormat: tile.valueFormat, + positiveIsGood: tile.positiveIsGood, + ...(tile.previousValue !== null ? { previousValue: tile.previousValue } : {}), + ...(tile.deltaContext ? { deltaContext: tile.deltaContext } : {}), + ...(tile.sparkline ? { sparkline: tile.sparkline } : {}), + ...(tile.icon ? { icon: tile.icon } : {}), + // 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 + + if (tile.href) { + return ( + + {tile.label} + + ) + } + + return +} diff --git a/resources/js/components/StatsPage.tsx b/resources/js/components/StatsPage.tsx new file mode 100644 index 0000000..500096d --- /dev/null +++ b/resources/js/components/StatsPage.tsx @@ -0,0 +1,66 @@ +import { Alert, Button } from "@javaabu/forms" +import { StencilForm } from "@javaabu/stencil" +import type { StatsExport, StatsPageProps } from "../types" +import { StatsChart } from "./StatsChart" + +function hiddenInputs(params: Record, prefix = ""): Array<[string, string]> { + const inputs: Array<[string, string]> = [] + + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined || value === "") continue + + const name = prefix ? `${prefix}[${key}]` : key + + if (Array.isArray(value)) { + for (const item of value) inputs.push([`${name}[]`, String(item)]) + } else if (typeof value === "object") { + inputs.push(...hiddenInputs(value as Record, name)) + } else { + inputs.push([name, String(value)]) + } + } + + return inputs +} + +/** A plain POST form that downloads the chart's data as CSV */ +export function StatsExportButton({ download, label }: { download: StatsExport; label: string }) { + return ( +
+ {download.token ? : null} + {hiddenInputs(download.params).map(([name, value], index) => ( + + ))} + +
+ ) +} + +/** + * The body of the stats pages: the stencil form, then the chart and the CSV + * download, or a message when there's nothing to show + */ +export function StatsPage({ page, form, chart, export: download, strings, errors }: StatsPageProps & { page: string }) { + const invalid = !chart && errors && Object.keys(errors).length > 0 + + return ( +
+

{strings.title}

+ + + + {chart ? ( + <> + + {download ? : null} + + ) : invalid ? ( + {strings.invalid} + ) : ( +

{strings.empty}

+ )} +
+ ) +} diff --git a/resources/js/index.ts b/resources/js/index.ts new file mode 100644 index 0000000..ea3f668 --- /dev/null +++ b/resources/js/index.ts @@ -0,0 +1,6 @@ +// What apps import as @javaabu/stats (through javaabu/manifest) +export { StatsChart, StatsTile, type StatsChartProps, type StatsTileProps } from "./components/StatsChart" +export { StatsPage, StatsExportButton } from "./components/StatsPage" +export { default as TimeSeriesStatsPage } from "./Pages/TimeSeries/Index" +export { default as CategoricalStatsPage } from "./Pages/Categorical/Index" +export type * from "./types" diff --git a/resources/js/types.ts b/resources/js/types.ts new file mode 100644 index 0000000..cbc10e5 --- /dev/null +++ b/resources/js/types.ts @@ -0,0 +1,122 @@ +import type { StencilSchema } from "@javaabu/stencil" + +/** Onu's `valueFormat` names that PHP sends */ +export type StatsValueFormat = "number" | "integer" | "compact" | "percent" | "currency" + +/** What every chart from PHP has (`Chart::toArray()`) */ +export interface StatsChartBase { + id: string + title: string + description?: string | null + valueFormat: StatsValueFormat + height?: number | null +} + +export interface StatsSeriesConfig { + label: string + color?: string +} + +/** The current period against the previous one (Onu's `ChartComparison`) */ +export interface StatsComparison { + previousKey: string + currentLabel?: string + previousLabel?: string + positiveIsGood?: boolean +} + +/** `TimeSeriesChart`: one row per step, keyed by series */ +export interface TimeSeriesChartData extends StatsChartBase { + type: "time-series" + data: Array> + /** The steps formatted with `config('stats.date_formats')`, for tables */ + labels: string[] + config: Record + totals: Record + dateKey: string + mode: "hour" | "day" | "week" | "month" | "year" + interval: "hour" | "day" | "week" | "month" | null + variant: "line" | "area" | "bar" + stacked: boolean + brush: boolean + comparison: StatsComparison | null + aggregateLabel: string | null +} + +/** `CategoryChart`: one row per category, keyed by series */ +export interface CategoryChartData extends StatsChartBase { + type: "category" + data: Array> + config: Record + categoryKey: string + categoryLabel: string | null + aggregateLabel: string | null + variant: "line" | "area" | "bar" + orientation: "vertical" | "horizontal" | null + stacked: boolean + comparison: StatsComparison | null +} + +/** `PieChart`: one slice per category, largest first */ +export interface PieChartData extends StatsChartBase { + type: "pie" + data: Array<{ name: string; value: number; categoryId: string | number | null }> + nameKey: string + valueKey: string + variant: "pie" | "donut" + /** null for the total, false for none */ + centerLabel: { value?: string; label?: string } | false | null + nameLabel: string | null + aggregateLabel: string | null + total: number +} + +/** `Sparkline`: one value per step */ +export interface SparklineData extends StatsChartBase { + type: "sparkline" + data: Array + label: string + variant: "line" | "area" + color: string | null + mode: string +} + +/** `StatTile`: a total, the previous total and a sparkline */ +export interface StatTileData extends StatsChartBase { + type: "stat-tile" + label: string + value: number | string | null + previousValue: number | null + deltaContext: string | null + positiveIsGood: boolean + sparkline: Array | null + icon: string | null + href: string | null + footer: string | null + period: string | null +} + +export type StatsChartData = TimeSeriesChartData | CategoryChartData | PieChartData | SparklineData | StatTileData + +/** The CSV download of a page */ +export interface StatsExport { + url: string + params: Record + token?: string | null +} + +export interface StatsPageStrings { + title: string + export: string + empty: string + invalid: string +} + +/** Props of `stats::TimeSeries/Index` and `stats::Categorical/Index` */ +export interface StatsPageProps { + form: StencilSchema + chart: StatsChartData | null + export: StatsExport | null + strings: StatsPageStrings + errors?: Record +} diff --git a/resources/views/categorical/index.blade.php b/resources/views/categorical/index.blade.php new file mode 100644 index 0000000..24c0600 --- /dev/null +++ b/resources/views/categorical/index.blade.php @@ -0,0 +1,5 @@ +@extends($layout) + +@section($layout_section) + @include('stats::partials.page', ['page' => 'categorical']) +@endsection diff --git a/resources/views/components/charts/cartesian.blade.php b/resources/views/components/charts/cartesian.blade.php new file mode 100644 index 0000000..14e7ed8 --- /dev/null +++ b/resources/views/components/charts/cartesian.blade.php @@ -0,0 +1,69 @@ +
merge(['class' => 'stats-chart', 'id' => $id]) }} data-slot="chart" data-chart-variant="{{ $svg['variant'] }}" aria-labelledby="{{ $id }}-title" @if($description) aria-describedby="{{ $id }}-description" @endif> + @include('stats::components.charts.header') + + @if($totals) +
+
+ {{ $totals['current_label'] }} + {{ $totals['current'] }} +
+
+ {{ $totals['previous_label'] }} + {{ $totals['previous'] }} +
+ @if($totals['delta']) + @include('stats::components.charts.delta', ['delta' => $totals['delta'], 'context' => null]) + @endif +
+ @endif + + @if($has_data) +
+ + + + @foreach($svg['series'] as $series) + + @if($series['area']) + + @endif + @if($series['line']) + + @endif + @foreach($series['bars'] as $bar) + {{ $bar['title'] }} + @endforeach + @foreach($series['points'] as $point) + 31) data-hidden @endif>{{ $point['title'] }} + @endforeach + + @endforeach + + + @if($legend) +
    + @foreach($legend as $item) +
  • {{ $item['label'] }}
  • + @endforeach +
+ @endif + +

{{ $summary }}

+
+ + @include('stats::components.charts.table', ['table' => $table]) + @else +

{{ $empty }}

+ @endif +
diff --git a/resources/views/components/charts/delta.blade.php b/resources/views/components/charts/delta.blade.php new file mode 100644 index 0000000..3fe1348 --- /dev/null +++ b/resources/views/components/charts/delta.blade.php @@ -0,0 +1,11 @@ +{{-- A change: arrow, sign and colour, read out as "Up 12%" --}} + + @if($delta['direction'] !== 'flat') + + @endif + + {{ $delta['spoken'] }} + @if(! empty($context)) + {{ $context }} + @endif + diff --git a/resources/views/components/charts/header.blade.php b/resources/views/components/charts/header.blade.php new file mode 100644 index 0000000..c67f5d3 --- /dev/null +++ b/resources/views/components/charts/header.blade.php @@ -0,0 +1,6 @@ +
+ {{ $title }} + @if($description) + {{ $description }} + @endif +
diff --git a/resources/views/components/charts/pie.blade.php b/resources/views/components/charts/pie.blade.php new file mode 100644 index 0000000..515fe04 --- /dev/null +++ b/resources/views/components/charts/pie.blade.php @@ -0,0 +1,40 @@ +
merge(['class' => 'stats-chart stats-chart--pie', 'id' => $id]) }} data-slot="chart" data-chart-variant="{{ $donut ? 'donut' : 'pie' }}" aria-labelledby="{{ $id }}-title" @if($description) aria-describedby="{{ $id }}-description" @endif> + @include('stats::components.charts.header') + + @if($has_data) +
+
+ + @foreach($slices as $slice) + @if($slice['path']) + {{ $slice['name'] }}: {{ $slice['value'] }} ({{ $slice['share'] }}) + @endif + @endforeach + + @if($center) + + @endif +
+ +
    + @foreach($slices as $slice) +
  • {{ $slice['name'] }}{{ $slice['share'] }}
  • + @endforeach +
+ +

{{ $summary }}

+
+ + @include('stats::components.charts.table', ['table' => [ + 'headers' => [$name_label, $value_label, $share_label], + 'rows' => array_map(fn (array $slice) => ['label' => $slice['name'], 'cells' => [$slice['value'], $slice['share']]], $slices), + ]]) + @else +

{{ $empty }}

+ @endif +
diff --git a/resources/views/components/charts/sparkline.blade.php b/resources/views/components/charts/sparkline.blade.php new file mode 100644 index 0000000..7aa689c --- /dev/null +++ b/resources/views/components/charts/sparkline.blade.php @@ -0,0 +1,10 @@ +
merge(['class' => 'stats-sparkline']) }} data-slot="sparkline" role="img" aria-label="{{ $label }}" style="--stats-series: {{ $color }}"> + +
diff --git a/resources/views/components/charts/stat-tile.blade.php b/resources/views/components/charts/stat-tile.blade.php new file mode 100644 index 0000000..58740a6 --- /dev/null +++ b/resources/views/components/charts/stat-tile.blade.php @@ -0,0 +1,34 @@ +@php($tag = $href ? 'a' : 'div') +<{{ $tag }} {{ $attributes->merge(['class' => 'stats-tile', 'id' => $id]) }} data-slot="stat-tile" @if($href) href="{{ $href }}" @else role="group" @endif aria-labelledby="{{ $id }}-label"> +
+ {{ $label }} + @if($icon) + + @endif +
+ +
{{ $value }}
+ + @if($delta) + @include('stats::components.charts.delta', ['delta' => $delta, 'context' => $delta_context]) + @elseif($period) + {{ $period }} + @endif + + @if($sparkline) + + @endif + + @if($footer) + + @endif + diff --git a/resources/views/components/charts/table.blade.php b/resources/views/components/charts/table.blade.php new file mode 100644 index 0000000..f05cb8e --- /dev/null +++ b/resources/views/components/charts/table.blade.php @@ -0,0 +1,24 @@ +
+ {{ $strings['view_table'] }} +
+ + + + @foreach($table['headers'] as $i => $header) + + @endforeach + + + + @foreach($table['rows'] as $row) + + + @foreach($row['cells'] as $cell) + + @endforeach + + @endforeach + +
0) class="stats-chart__number" @endif>{{ $header }}
{{ $row['label'] }}{{ $cell }}
+
+
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 --}} +
merge(['class' => 'stats-tiles']) }} data-slot="stat-tiles" data-columns="{{ $columns }}"> + {{ $slot }} +
diff --git a/resources/views/material-admin-26/categorical-stats/_form.blade.php b/resources/views/material-admin-26/categorical-stats/_form.blade.php deleted file mode 100644 index b19622a..0000000 --- a/resources/views/material-admin-26/categorical-stats/_form.blade.php +++ /dev/null @@ -1,124 +0,0 @@ -
-
- - - @foreach($filters as $filter => $value) - - @endforeach -
- -
- -
- -
-
- -
-
-
- - -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- @php - $compares = [ - ' ' => __('No Comparison'), - 1 => __('Previous Period'), - 0 => __('Custom Period'), - ]; - @endphp - - -
-
-
- -
-
-
-
- -
-
-
-
- - {{ __('Generate') }} - - - - {{ __('Download') }} - -
-
-
diff --git a/resources/views/material-admin-26/categorical-stats/_generator.blade.php b/resources/views/material-admin-26/categorical-stats/_generator.blade.php deleted file mode 100644 index 03f50d5..0000000 --- a/resources/views/material-admin-26/categorical-stats/_generator.blade.php +++ /dev/null @@ -1,15 +0,0 @@ - - - @include('stats::material-admin-26.categorical-stats._form') - - - -
-
- -
-
- -@pushonce(config('stats.scripts_stack')) - @include('stats::material-admin-26.categorical-stats._script') -@endpushonce diff --git a/resources/views/material-admin-26/categorical-stats/_script.blade.php b/resources/views/material-admin-26/categorical-stats/_script.blade.php deleted file mode 100644 index cbf6fe4..0000000 --- a/resources/views/material-admin-26/categorical-stats/_script.blade.php +++ /dev/null @@ -1,215 +0,0 @@ - - diff --git a/resources/views/material-admin-26/categorical-stats/index.blade.php b/resources/views/material-admin-26/categorical-stats/index.blade.php deleted file mode 100644 index bd1af14..0000000 --- a/resources/views/material-admin-26/categorical-stats/index.blade.php +++ /dev/null @@ -1,8 +0,0 @@ -@extends(config('stats.default_layout')) - -@section('title', __('Categorical Stats')) -@section('page-title', __('Categorical Stats')) - -@section('content') - -@endsection diff --git a/resources/views/material-admin-26/time-series-stats/_form.blade.php b/resources/views/material-admin-26/time-series-stats/_form.blade.php deleted file mode 100644 index d30524f..0000000 --- a/resources/views/material-admin-26/time-series-stats/_form.blade.php +++ /dev/null @@ -1,93 +0,0 @@ -
-
- - - @foreach($filters as $filter => $value) - - @endforeach -
- -
- -
- -
-
- -
-
-
- - -
-
- -
-
- -
-
- -
-
- -
-
- @php - $compares = [ - ' ' => __('No Comparision'), - 1 => __('Previous Period'), - 0 => __('Custom Period'), - ] - @endphp - - -
-
-
- -
-
-
-
- -
-
-
-
- - {{ __('Generate') }} - - - - {{ __('Download') }} - -
-
-
diff --git a/resources/views/material-admin-26/time-series-stats/_generator.blade.php b/resources/views/material-admin-26/time-series-stats/_generator.blade.php deleted file mode 100644 index f402cc3..0000000 --- a/resources/views/material-admin-26/time-series-stats/_generator.blade.php +++ /dev/null @@ -1,15 +0,0 @@ - - - @include('stats::material-admin-26.time-series-stats._form') - - - -
-
- -
-
- -@pushonce(config('stats.scripts_stack')) - @include('stats::material-admin-26.time-series-stats._script') -@endpushonce diff --git a/resources/views/material-admin-26/time-series-stats/_script.blade.php b/resources/views/material-admin-26/time-series-stats/_script.blade.php deleted file mode 100644 index e994168..0000000 --- a/resources/views/material-admin-26/time-series-stats/_script.blade.php +++ /dev/null @@ -1,225 +0,0 @@ - - diff --git a/resources/views/material-admin-26/time-series-stats/index.blade.php b/resources/views/material-admin-26/time-series-stats/index.blade.php deleted file mode 100644 index 8c08625..0000000 --- a/resources/views/material-admin-26/time-series-stats/index.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -@extends('stats::material-admin-26.time-series-stats.stats') - -@section('page-title', __('Time Series Statistics')) - -@section('content') - -@endsection diff --git a/resources/views/material-admin-26/time-series-stats/stats.blade.php b/resources/views/material-admin-26/time-series-stats/stats.blade.php deleted file mode 100644 index 0d9b2a9..0000000 --- a/resources/views/material-admin-26/time-series-stats/stats.blade.php +++ /dev/null @@ -1,4 +0,0 @@ -@extends(config('stats.default_layout')) - -@section('title', 'Time Series Stats') -@section('page-title', __('Time Series Stats')) diff --git a/resources/views/partials/page.blade.php b/resources/views/partials/page.blade.php new file mode 100644 index 0000000..4c7ce40 --- /dev/null +++ b/resources/views/partials/page.blade.php @@ -0,0 +1,28 @@ +{{-- The stats page body: the form, then the chart and the CSV download --}} +
+

{{ $strings['title'] }}

+ + {{ $form }} + + @if($chart) + + + @if($export) +
+ @csrf + @foreach(\Illuminate\Support\Arr::dot(array_filter($export['params'], fn ($value) => $value !== null && $value !== '' && $value !== [])) as $name => $value) + @php + $segments = explode('.', $name); + $input_name = array_shift($segments) . implode('', array_map(fn ($segment) => is_numeric($segment) ? '[]' : "[{$segment}]", $segments)); + @endphp + + @endforeach + {{ $strings['export'] }} +
+ @endif + @elseif(isset($errors) && $errors->any()) + {{ $strings['invalid'] }} + @else +

{{ $strings['empty'] }}

+ @endif +
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..6f4aa9d --- /dev/null +++ b/src/Charts/Chart.php @@ -0,0 +1,344 @@ +`) 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; + + protected ?string $computed_for = 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 + { + $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(); + } + + 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 + { + $this->computed_for = $this->signature(); + + 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(), + ); + } + + /** + * 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 + */ + 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..a47ad00 --- /dev/null +++ b/src/Charts/StatTile.php @@ -0,0 +1,223 @@ +` 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, 'previousValue' => null, 'sparkline' => null, 'period' => 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 [ + 'value' => $stat->total(), + 'previousValue' => $previous, + 'sparkline' => $sparkline, + '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, + ]; + } + + /** + * 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, + '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..f0a793e --- /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->displayDate($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..4ec4f44 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.'], ]; } @@ -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); @@ -73,7 +79,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..8215c32 100644 --- a/src/Concerns/ExportsCategoricalStats.php +++ b/src/Concerns/ExportsCategoricalStats.php @@ -2,69 +2,41 @@ namespace Javaabu\Stats\Concerns; -use Illuminate\Validation\Rule; +use Illuminate\Support\Facades\Gate; 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 { + $metric = $request->metric(); + + abort_unless(in_array($metric, CategoricalStats::allowedMetrics(array_merge($request->fixedFilters(), $filters), $request->statsUser()), true), 403); + + $stats = $request->stats(); + $compare = $request->compareStats(); + if ($filters) { - $this->validate($request, [ - 'metric' => [ - 'required', - 'string', - Rule::in(CategoricalStats::allowedMetrics($filters, $request->user())), - ], - ]); + $stats->setFilters(array_merge($stats->getFilters(), $filters)); + $compare?->setFilters(array_merge($compare->getFilters(), $filters)); } - } - /** - * @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; + $user = $request->statsUser(); - 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(); - } + abort_unless($user && Gate::forUser($user)->allows('export', $stats), 403); - $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..203bf24 100644 --- a/src/Concerns/ExportsTimeSeriesStats.php +++ b/src/Concerns/ExportsTimeSeriesStats.php @@ -1,89 +1,42 @@ 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->input('metric'); - $mode = TimeSeriesModes::from($request->input('mode')); + $metric = $request->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'), - ); - } + abort_unless(in_array($metric, TimeSeriesStats::allowedMetrics(array_merge($request->fixedFilters(), $filters), $request->statsUser()), true), 403); - $compare_range = null; + $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(); - } - - $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); + $user = $request->statsUser(); - $title = $exporter->getReportTitle(); + abort_unless($user && Gate::forUser($user)->allows('export', $stats), 403); - $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..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,16 +15,18 @@ /** @var $this InteractsWithDateRange */ trait HasDateRange { - protected Carbon $date_from; - protected Carbon $date_to; + protected CarbonInterface $date_from; + + 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; } @@ -37,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)); } @@ -56,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/Concerns/HasFilters.php b/src/Concerns/HasFilters.php index 4e9cf85..4f88ecb 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; @@ -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/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/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/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..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,19 +12,18 @@ 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 */ public function getDateRange(): DateRange; - /** * Get the formatted date from to */ @@ -32,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 @@ -47,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 @@ -69,7 +69,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..79bac62 100644 --- a/src/Enums/PresetDateRanges.php +++ b/src/Enums/PresetDateRanges.php @@ -2,16 +2,19 @@ namespace Javaabu\Stats\Enums; -use Carbon\Carbon; -use Javaabu\Helpers\Enums\IsEnum; -use Javaabu\Helpers\Enums\NativeEnumsTrait; +use Carbon\CarbonInterface; +use Illuminate\Support\Facades\Date; 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'; @@ -28,11 +31,11 @@ enum PresetDateRanges: string implements IsEnum, 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(), @@ -48,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(), @@ -71,7 +74,27 @@ public function getName(): string return $this->value; } - protected function generatePreviousFromDate(): Carbon + 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(): CarbonInterface { $from_date = $this->getDateFrom(); @@ -93,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 4b4caf8..369418d 100644 --- a/src/Enums/TimeSeriesModes.php +++ b/src/Enums/TimeSeriesModes.php @@ -2,15 +2,18 @@ namespace Javaabu\Stats\Enums; -use Carbon\Carbon; +use Carbon\CarbonInterface; 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,41 +47,109 @@ public function queryMethodName(): string return Str::camel($this->value); } - public function interval(Carbon $date_from, Carbon $date_to): int + /** + * The number of steps between the two dates, not counting the first + */ + public function interval(CarbonInterface $date_from, CarbonInterface $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_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(CarbonInterface $date): CarbonInterface { + $date = $date->copy(); + 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->startOfHour(), + self::DAY => $date->startOfDay(), + self::WEEK => $date->startOfWeek(TimeSeriesStats::firstDayOfWeek())->startOfDay(), + self::MONTH => $date->startOfMonth(), + self::YEAR => $date->startOfYear(), }; } - public function increment(Carbon $date): Carbon + public function increment(CarbonInterface $date): CarbonInterface { $date = $date->copy()->locale(TimeSeriesStats::dateLocale()); @@ -87,7 +158,10 @@ public function increment(Carbon $date): Carbon return $date->{$method}(); } - public function formatDate(Carbon $date, bool $for_display = true): string + /** + * Format a date for display, or as the key the database groups by + */ + public function formatDate(CarbonInterface $date, bool $for_display = true): string { $date = $date->copy()->locale(TimeSeriesStats::dateLocale()); @@ -95,7 +169,47 @@ 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()); } + /** + * 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(CarbonInterface $date, ?string $locale = null): string + { + $locale ??= app()->getLocale(); + + if ($this === self::WEEK) { + $week = $date->copy()->locale(TimeSeriesStats::dateLocale()); + + return __('Week :week, :year', ['week' => $week->isoFormat('w'), 'year' => $week->isoFormat('gggg')]); + } + + return $date->copy()->locale($locale)->isoFormat($this->getDateFormat()); + } + + /** + * The step as an ISO 8601 date, for charts with a time axis + */ + public function toIsoDate(CarbonInterface $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
+ * ``` + */ +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/Cache/StatsCacheTest.php b/tests/Feature/Cache/StatsCacheTest.php new file mode 100644 index 0000000..1d0001b --- /dev/null +++ b/tests/Feature/Cache/StatsCacheTest.php @@ -0,0 +1,189 @@ +registerTestStats(); + $this->seedPayments(); + } + + protected function tile(): StatTile + { + return StatTile::make('payments_count')->range(new ExactDateRange('2024-07-01', '2024-07-04 23:59:59')); + } + + protected function addPayment(): void + { + PaymentFactory::new()->withUser()->create(['paid_at' => '2024-07-04 10:00:00']); + } + + #[Test] + public function it_caches_chart_data_until_the_metric_is_forgotten(): void + { + $this->assertEquals(5, $this->tile()->toArray()['value']); + + $this->addPayment(); + + $this->assertEquals(5, $this->tile()->toArray()['value']); + + Stats::forget('payments_count'); + + $this->assertEquals(6, $this->tile()->toArray()['value']); + } + + #[Test] + public function it_forgets_every_metric_when_flushed(): void + { + $this->tile()->toArray(); + $this->addPayment(); + + Stats::flush(); + + $this->assertEquals(6, $this->tile()->toArray()['value']); + } + + #[Test] + public function it_does_not_forget_other_metrics(): void + { + $this->tile()->toArray(); + $this->addPayment(); + + Stats::forget('payments_sum'); + + $this->assertEquals(5, $this->tile()->toArray()['value']); + } + + #[Test] + public function it_skips_the_cache_when_turned_off(): void + { + $this->tile()->withoutCache()->toArray(); + $this->addPayment(); + + $this->assertEquals(6, $this->tile()->withoutCache()->toArray()['value']); + $this->assertFalse($this->tile()->withoutCache()->isWarm()); + + config()->set('stats.cache.enabled', false); + $this->addPayment(); + + $this->assertEquals(7, $this->tile()->toArray()['value']); + } + + #[Test] + public function it_keeps_past_periods_longer_than_current_ones(): void + { + $cache = app(StatsCache::class); + + $this->assertEquals(600, $cache->ttlFor(now()->endOfDay())); + $this->assertEquals(86400, $cache->ttlFor(now()->subDay()->endOfDay())); + + config()->set('stats.cache.ttl.current', 60); + + $this->assertEquals(60, $cache->ttlFor(null)); + } + + #[Test] + public function it_uses_the_charts_own_ttl(): void + { + $tile = $this->tile()->cache(minutes: 5); + + $tile->toArray(); + $this->addPayment(); + $this->assertEquals(5, $this->tile()->cache(minutes: 5)->toArray()['value']); + + $this->travel(6)->minutes(); + + $this->assertEquals(6, $this->tile()->cache(minutes: 5)->toArray()['value']); + } + + #[Test] + public function it_keys_the_cache_by_what_the_chart_shows(): void + { + $this->tile()->toArray(); + + $this->assertTrue($this->tile()->isWarm()); + $this->assertFalse($this->tile()->compare()->isWarm()); + $this->assertFalse(StatTile::make('payments_count')->range(PresetDateRanges::TODAY)->isWarm()); + + app()->setLocale('dv'); + $this->assertFalse($this->tile()->isWarm()); + } + + #[Test] + public function it_invalidates_the_stats_of_models_with_the_trait(): void + { + $this->tile()->toArray(); + + CachedPayment::query()->create(['amount' => 5, 'paid_at' => '2024-07-04 11:00:00']); + + $this->assertEquals(6, $this->tile()->toArray()['value']); + + $this->tile()->toArray(); + CachedPayment::query()->latest('id')->first()->delete(); + + $this->assertEquals(5, $this->tile()->toArray()['value']); + } + + #[Test] + public function it_warms_the_registered_charts(): void + { + Stats::warm([$this->tile()]); + Stats::warm(fn () => TimeSeriesChart::make('payments_sum')->range(PresetDateRanges::LAST_7_DAYS)); + + $this->assertFalse($this->tile()->isWarm()); + + $this->artisan('stats:warm') + ->expectsOutputToContain('Warmed 2 charts.') + ->assertSuccessful(); + + $this->assertTrue($this->tile()->isWarm()); + $this->assertTrue(TimeSeriesChart::make('payments_sum')->range(PresetDateRanges::LAST_7_DAYS)->isWarm()); + } + + #[Test] + public function it_refreshes_what_is_cached_when_warming(): void + { + Stats::warm($this->tile()); + + $this->tile()->toArray(); + $this->addPayment(); + + (new WarmStatsCache)->handle(app(StatsManager::class)); + + $this->assertEquals(6, $this->tile()->toArray()['value']); + } + + #[Test] + public function it_can_queue_the_warming_job(): void + { + Queue::fake(); + config()->set('stats.cache.warm.queue', 'stats'); + + $this->artisan('stats:warm --queue --flush')->assertSuccessful(); + + Queue::assertPushedOn('stats', WarmStatsCache::class); + } +} diff --git a/tests/Feature/Charts/CategoricalChartsTest.php b/tests/Feature/Charts/CategoricalChartsTest.php new file mode 100644 index 0000000..d60fa7b --- /dev/null +++ b/tests/Feature/Charts/CategoricalChartsTest.php @@ -0,0 +1,118 @@ +registerTestStats(); + } + + protected function july(): ExactDateRange + { + return new ExactDateRange('2024-07-01', '2024-07-04 23:59:59'); + } + + #[Test] + public function it_has_a_row_per_category_with_data(): void + { + [$ahmed, $aisha] = $this->seedPayments(); + + $chart = CategoryChart::make('payments_by_user')->range($this->july())->toArray(); + + $this->assertEquals('category', $chart['type']); + $this->assertEquals('category', $chart['categoryKey']); + $this->assertEquals('User', $chart['categoryLabel']); + $this->assertEquals([ + ['category' => 'އަޙްމަދު', 'categoryId' => $ahmed->id, 'payments_by_user' => 3], + ['category' => 'ޢާއިޝާ', 'categoryId' => $aisha->id, 'payments_by_user' => 2], + ], $chart['data']); + $this->assertEquals('bar', $chart['variant']); + $this->assertEquals('vertical', $chart['orientation']); + } + + #[Test] + public function it_shows_every_category_or_the_chosen_ones(): void + { + [, $aisha] = $this->seedPayments(); + User::factory()->create(['name' => 'ޙަސަން']); + + $all = CategoryChart::make('payments_by_user')->range($this->july())->categoryMode(CategoricalModes::ALL)->toArray(); + $this->assertCount(3, $all['data']); + $this->assertEquals(0, $all['data'][2]['payments_by_user']); + + $chosen = CategoryChart::make('payments_by_user')->range($this->july())->categories([$aisha->id])->toArray(); + $this->assertEquals(['ޢާއިޝާ'], array_column($chosen['data'], 'category')); + } + + #[Test] + public function it_keeps_the_top_categories(): void + { + $this->seedPayments(); + + $chart = CategoryChart::make('payments_by_user')->range($this->july())->top(1)->horizontal()->toArray(); + + $this->assertEquals(['އަޙްމަދު'], array_column($chart['data'], 'category')); + $this->assertEquals('horizontal', $chart['orientation']); + } + + #[Test] + public function it_plots_several_categorical_stats_and_comparisons(): void + { + $this->seedPayments(); + + $several = CategoryChart::make()->stats(['payments_by_user', 'payment_amounts_by_user'])->range($this->july())->line()->toArray(); + $this->assertEquals(['payments_by_user', 'payment_amounts_by_user'], array_keys($several['config'])); + $this->assertEquals(300, $several['data'][0]['payment_amounts_by_user']); + $this->assertNull($several['orientation']); + + $compared = CategoryChart::make('payments_by_user')->range(new ExactDateRange('2024-07-03', '2024-07-04 23:59:59'))->compare()->toArray(); + $this->assertEquals('previous', $compared['comparison']['previousKey']); + $this->assertEquals([0, 0], array_column($compared['data'], 'previous')); + } + + #[Test] + public function it_makes_pie_slices_largest_first_with_the_rest_as_other(): void + { + $this->seedPayments(); + + $pie = PieChart::make('payments_by_user')->range($this->july())->donut()->top(1)->toArray(); + + $this->assertEquals('pie', $pie['type']); + $this->assertEquals('donut', $pie['variant']); + $this->assertEquals([ + ['name' => 'އަޙްމަދު', 'value' => 3], + ['name' => 'Other', 'value' => 2], + ], array_map(fn ($slice) => ['name' => $slice['name'], 'value' => $slice['value']], $pie['data'])); + $this->assertEquals(5, $pie['total']); + $this->assertNull($pie['centerLabel']); + } + + #[Test] + public function it_sets_the_donut_centre(): void + { + $this->seedPayments(); + + $pie = PieChart::make('payments_by_user')->range($this->july())->donut()->centerLabel('MVR 4.2M', 'ބަޖެޓް')->toArray(); + $this->assertEquals(['value' => 'MVR 4.2M', 'label' => 'ބަޖެޓް'], $pie['centerLabel']); + + $none = PieChart::make('payments_by_user')->range($this->july())->donut()->centerLabel(false)->toArray(); + $this->assertFalse($none['centerLabel']); + } +} diff --git a/tests/Feature/Charts/TilesAndSparklinesTest.php b/tests/Feature/Charts/TilesAndSparklinesTest.php new file mode 100644 index 0000000..264a928 --- /dev/null +++ b/tests/Feature/Charts/TilesAndSparklinesTest.php @@ -0,0 +1,83 @@ +registerTestStats(); + } + + #[Test] + public function it_gives_a_sparkline_one_value_per_step(): void + { + $this->seedPayments(); + + $sparkline = Sparkline::make('payments_count') + ->mode(TimeSeriesModes::DAY) + ->range(new ExactDateRange('2024-07-01', '2024-07-04 23:59:59')) + ->area() + ->color('var(--chart-3)') + ->toArray(); + + $this->assertEquals('sparkline', $sparkline['type']); + $this->assertEquals([0, 0, 3, 2], $sparkline['data']); + $this->assertEquals('Payments', $sparkline['label']); + $this->assertEquals('area', $sparkline['variant']); + $this->assertEquals('var(--chart-3)', $sparkline['color']); + } + + #[Test] + public function it_gives_a_tile_the_total_the_previous_total_and_a_sparkline(): void + { + $this->seedPayments(); + + $tile = StatTile::make('payments_count') + ->range(new ExactDateRange('2024-07-01', '2024-07-04 23:59:59')) + ->compare() + ->sparkline(TimeSeriesModes::DAY) + ->icon('credit-card') + ->href('/payments') + ->label('ފައިސާ') + ->toArray(); + + $this->assertEquals('stat-tile', $tile['type']); + $this->assertEquals('ފައިސާ', $tile['label']); + $this->assertEquals(5, $tile['value']); + $this->assertEquals(1, $tile['previousValue']); + $this->assertEquals('vs previous period', $tile['deltaContext']); + $this->assertEquals([0, 0, 3, 2], $tile['sparkline']); + $this->assertEquals('credit-card', $tile['icon']); + $this->assertEquals('/payments', $tile['href']); + } + + #[Test] + public function it_has_no_delta_or_sparkline_unless_asked(): void + { + $this->seedPayments(); + + $tile = Stats::tile('payments_sum')->range(new ExactDateRange('2024-07-01', '2024-07-04 23:59:59'))->valueFormat('currency')->toArray(); + + $this->assertEquals(400, $tile['value']); + $this->assertNull($tile['previousValue']); + $this->assertNull($tile['deltaContext']); + $this->assertNull($tile['sparkline']); + $this->assertEquals('currency', $tile['valueFormat']); + } +} diff --git a/tests/Feature/Charts/TimeSeriesChartTest.php b/tests/Feature/Charts/TimeSeriesChartTest.php new file mode 100644 index 0000000..67e274f --- /dev/null +++ b/tests/Feature/Charts/TimeSeriesChartTest.php @@ -0,0 +1,167 @@ +registerTestStats(); + } + + #[Test] + public function it_has_one_row_per_step_with_zero_for_steps_without_data(): void + { + $this->seedPayments(); + + $chart = TimeSeriesChart::make('payments_count') + ->mode(TimeSeriesModes::DAY) + ->range(new ExactDateRange('2024-07-01', '2024-07-04 23:59:59')) + ->toArray(); + + $this->assertEquals('time-series', $chart['type']); + $this->assertEquals('Payments', $chart['title']); + $this->assertEquals([ + ['date' => '2024-07-01', 'payments_count' => 0], + ['date' => '2024-07-02', 'payments_count' => 0], + ['date' => '2024-07-03', 'payments_count' => 3], + ['date' => '2024-07-04', 'payments_count' => 2], + ], $chart['data']); + $this->assertEquals(['1 Jul 24', '2 Jul 24', '3 Jul 24', '4 Jul 24'], $chart['labels']); + $this->assertEquals(['payments_count' => ['label' => 'Payments']], $chart['config']); + $this->assertEquals('day', $chart['interval']); + $this->assertEquals(['payments_count' => 5], $chart['totals']); + $this->assertNull($chart['comparison']); + } + + #[Test] + public function it_plots_several_stats_with_their_own_labels(): void + { + $this->seedPayments(); + + $chart = TimeSeriesChart::make() + ->stats(['payments_count' => 'ފައިސާ', 'payments_sum']) + ->mode('day') + ->range(new ExactDateRange('2024-07-03', '2024-07-04 23:59:59')) + ->bar() + ->stacked() + ->toArray(); + + $this->assertEquals(['payments_count', 'payments_sum'], array_keys($chart['config'])); + $this->assertEquals('ފައިސާ', $chart['config']['payments_count']['label']); + $this->assertEquals(300, $chart['data'][0]['payments_sum']); + $this->assertEquals('bar', $chart['variant']); + $this->assertTrue($chart['stacked']); + } + + #[Test] + public function it_compares_the_first_stat_with_the_previous_period(): void + { + $this->seedPayments(); + + $chart = TimeSeriesChart::make('payments_count') + ->mode(TimeSeriesModes::DAY) + ->range(new ExactDateRange('2024-07-03', '2024-07-04 23:59:59')) + ->compare() + ->positiveIsGood(false) + ->toArray(); + + $this->assertEquals(['payments_count', 'previous'], array_keys($chart['config'])); + $this->assertEquals(['date' => '2024-07-03', 'payments_count' => 3, 'previous' => 0], $chart['data'][0]); + $this->assertEquals('previous', $chart['comparison']['previousKey']); + $this->assertEquals('3 Jul 2024 – 4 Jul 2024', $chart['comparison']['currentLabel']); + $this->assertEquals('1 Jul 2024 – 2 Jul 2024', $chart['comparison']['previousLabel']); + $this->assertFalse($chart['comparison']['positiveIsGood']); + } + + #[Test] + public function it_plots_one_stat_over_several_periods(): void + { + $this->seedPayments(); + + $chart = TimeSeriesChart::make('payments_count') + ->mode(TimeSeriesModes::MONTH) + ->periods([PresetDateRanges::THIS_YEAR, PresetDateRanges::LAST_YEAR]) + ->toArray(); + + $this->assertEquals(['period_1', 'period_2'], array_keys($chart['config'])); + $this->assertEquals('This year', $chart['config']['period_1']['label']); + $this->assertEquals('Last year', $chart['config']['period_2']['label']); + $this->assertCount(12, $chart['data']); + $this->assertEquals(['date' => '2024-07-01', 'period_1' => 5, 'period_2' => 0], $chart['data'][6]); + $this->assertEquals(1, $chart['data'][5]['period_1']); + } + + #[Test] + public function it_groups_by_week_and_year(): void + { + $this->seedPayments(); + + $weeks = TimeSeriesChart::make('payments_count')->mode(TimeSeriesModes::WEEK)->range(new ExactDateRange('2024-06-23', '2024-07-06 23:59:59'))->toArray(); + + $this->assertEquals([ + ['date' => '2024-06-23', 'payments_count' => 1], + ['date' => '2024-06-30', 'payments_count' => 5], + ], $weeks['data']); + + $years = TimeSeriesChart::make('payments_count')->mode(TimeSeriesModes::YEAR)->range(PresetDateRanges::LAST_5_YEARS)->toArray(); + + $this->assertCount(5, $years['data']); + $this->assertEquals(['date' => '2024-01-01', 'payments_count' => 6], end($years['data'])); + $this->assertNull($years['interval']); + } + + #[Test] + public function it_applies_the_charts_filters(): void + { + [$ahmed] = $this->seedPayments(); + + $chart = TimeSeriesChart::make('payments_count') + ->mode(TimeSeriesModes::DAY) + ->range(new ExactDateRange('2024-07-03', '2024-07-04 23:59:59')) + ->filters(['user' => $ahmed->id]) + ->toArray(); + + $this->assertEquals([3, 0], array_column($chart['data'], 'payments_count')); + } + + #[Test] + public function it_refuses_metrics_that_are_not_registered(): void + { + $this->expectException(StatsException::class); + + TimeSeriesChart::make('unknown')->withoutCache()->toArray(); + } + + #[Test] + public function it_is_json_serializable_with_its_options(): void + { + $this->seedPayments(); + + $json = json_decode(json_encode( + TimeSeriesChart::make('payments_count')->title('ފައިސާ')->description('ރުފިޔާ')->valueFormat('integer')->height(200)->id('payments')->brush() + ), true); + + $this->assertEquals('payments', $json['id']); + $this->assertEquals('ފައިސާ', $json['title']); + $this->assertEquals('ރުފިޔާ', $json['description']); + $this->assertEquals('integer', $json['valueFormat']); + $this->assertEquals(200, $json['height']); + $this->assertTrue($json['brush']); + } +} 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/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']); + } +} diff --git a/tests/Feature/Http/RoutesAndAuthorizationTest.php b/tests/Feature/Http/RoutesAndAuthorizationTest.php new file mode 100644 index 0000000..67d0983 --- /dev/null +++ b/tests/Feature/Http/RoutesAndAuthorizationTest.php @@ -0,0 +1,156 @@ +set('auth.guards.web_admin', ['driver' => 'session', 'provider' => 'admins']); + $app['config']->set('auth.providers.admins', ['driver' => 'eloquent', 'model' => Admin::class]); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->registerTestStats(); + } + + #[Test] + public function it_registers_the_pages_with_a_prefix_name_and_guard(): void + { + Route::middleware('web')->group(fn () => Route::stats([ + 'prefix' => 'admin/stats', + 'name' => 'admin.stats', + 'guard' => 'web_admin', + ])); + Route::getRoutes()->refreshNameLookups(); + + $this->assertEquals(url('admin/stats/time-series'), route('admin.stats.time-series.index')); + $this->assertEquals(url('admin/stats/time-series'), route('admin.stats.time-series.export')); + $this->assertEquals(url('admin/stats/categorical'), route('admin.stats.categorical.index')); + $this->assertEquals(url('admin/stats/categorical/categories'), route('admin.stats.categorical.categories')); + $this->assertEquals(['web', 'auth:web_admin'], Route::getRoutes()->getByName('admin.stats.time-series.index')->gatherMiddleware()); + + Gate::define('view_stats', fn ($user) => $user instanceof Admin); + TimeSeriesStats::register(['payments_by_status' => PaymentsByStatusCount::class], false); + + $admin = Admin::query()->forceCreate(['name' => 'ވެރިޔާ', 'email' => 'admin@example.com']); + + $this->actingAs($admin, 'web_admin') + ->get('/admin/stats/time-series') + ->assertOk() + ->assertSee(url('admin/stats/time-series'), false); + } + + #[Test] + public function it_keeps_users_of_other_guards_out(): void + { + Route::middleware('web')->group(fn () => Route::stats(['prefix' => 'admin/stats', 'guard' => 'web_admin'])); + Gate::define('view_stats', fn () => true); + + $this->actingAs(User::query()->forceCreate(['name' => 'ބޭނުންކުރާ', 'email' => 'user@example.com']), 'web') + ->getJson('/admin/stats/time-series') + ->assertUnauthorized(); + } + + #[Test] + public function it_registers_only_the_stats_asked_for(): void + { + Route::stats(['categorical' => false]); + Route::statsApi(['time_series' => false, 'middleware' => ['api']]); + Route::getRoutes()->refreshNameLookups(); + + $this->assertTrue(Route::has('stats.time-series.index')); + $this->assertFalse(Route::has('stats.categorical.index')); + $this->assertFalse(Route::has('api.stats.time-series.index')); + $this->assertTrue(Route::has('api.stats.categorical.index')); + $this->assertTrue(Route::has('api.stats.categorical.categories')); + $this->assertEquals(['api'], Route::getRoutes()->getByName('api.stats.categorical.index')->gatherMiddleware()); + } + + #[Test] + public function it_uses_controllers_from_the_options_or_the_config(): void + { + Route::stats(['controllers' => ['time_series' => IslandStatsController::class]]); + Route::getRoutes()->refreshNameLookups(); + + $this->assertEquals(IslandStatsController::class . '@index', Route::getRoutes()->getByName('stats.time-series.index')->getActionName()); + + config()->set('stats.controllers.time_series', IslandStatsController::class); + Route::stats(['name' => 'other', 'prefix' => 'other']); + Route::getRoutes()->refreshNameLookups(); + + $this->assertEquals(IslandStatsController::class . '@export', Route::getRoutes()->getByName('other.time-series.export')->getActionName()); + } + + #[Test] + public function it_adds_the_controllers_fixed_filters(): void + { + [$ahmed, $aisha] = $this->seedPayments(); + $this->allowViewingStats(); + + Route::middleware('web')->group(fn () => Route::stats(['controllers' => ['time_series' => IslandStatsController::class]])); + + $this->actingAs($ahmed) + ->get("/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('name="filters[user]"', false) + ->assertSee('3 Jul 24 · Payments: 0', false) + ->assertSee('4 Jul 24 · Payments: 2', false); + } + + #[Test] + public function it_registers_the_view_stats_permission_and_the_policy(): void + { + $this->assertTrue(Permissions::has(StatsPolicy::VIEW)); + $this->assertEquals('View stats', Permissions::description(StatsPolicy::VIEW)); + $this->assertEquals(StatsPolicy::class, Permissions::policies()[TimeSeriesStatsRepository::class]); + } + + #[Test] + public function it_lets_stats_through_the_policy(): void + { + $user = User::factory()->create(); + $stat = new PaymentsByStatusCount; + + $this->assertFalse($stat->canView($user)); + $this->assertFalse($stat->canView(null)); + + Gate::define('view_stats', fn () => true); + + $this->assertTrue($stat->canView($user)); + $this->assertTrue(Gate::forUser($user)->allows('export', $stat)); + } + + #[Test] + public function it_lets_the_app_replace_the_policy(): void + { + Gate::define('view_stats', fn () => true); + Gate::policy(TimeSeriesStatsRepository::class, DenyAllPolicy::class); + + $this->assertFalse((new PaymentsByStatusCount)->canView(User::factory()->create())); + } +} 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('actingAs($ahmed) + ->get('/stats/time-series?metric=payments_count&mode=week&date_range=custom&date_from=2024-07-01&date_to=2024-07-04&compare_to=previous') + ->assertOk() + ->assertSee('data-slot="period-current-total">5<', false) + ->assertSee('data-slot="period-previous-total">1<', false); + + $this->actingAs($ahmed) + ->get('/stats/categorical?metric=payments_by_user&mode=non_empty&date_range=last_7_days&compare_to=previous') + ->assertOk() + ->assertSee('ޢާއިޝާ · Last 7 days: 3', false) + ->assertSee('ޢާއިޝާ · 21 Jun 2024 – 27 Jun 2024: 0', false); + + $csv = $this->actingAs($ahmed) + ->post('/stats/time-series', [ + 'metric' => 'payments_count', + 'mode' => 'day', + 'date_from' => '2024-07-03', + 'date_to' => '2024-07-04', + ]) + ->assertOk() + ->streamedContent(); + + $this->assertStringContainsString('2024-07-03,3', $csv); + $this->assertStringContainsString('2024-07-04,2', $csv); + + $tile = StatTile::make('payments_count')->range(PresetDateRanges::THIS_WEEK)->compare()->sparkline()->toArray(); + + $this->assertSame(5, $tile['value']); + } + + #[Test] + public function it_serves_the_json_api(): void + { + [$ahmed] = $this->seedPayments(); + Route::middleware('web')->group(fn () => Route::statsApi(['prefix' => 'api/stats'])); + + $this->actingAs($ahmed) + ->getJson('/api/stats/time-series?metric=payments_count&mode=hour&date_range=today&format=default') + ->assertOk(); + + $this->actingAs($ahmed) + ->getJson('/api/stats/time-series?metric=payments_count&mode=month&date_range=lifetime&format=chartjs&compare_date_range=last_year') + ->assertOk(); + } +} diff --git a/tests/Feature/ManifestRegistrationTest.php b/tests/Feature/ManifestRegistrationTest.php new file mode 100644 index 0000000..c1df67b --- /dev/null +++ b/tests/Feature/ManifestRegistrationTest.php @@ -0,0 +1,73 @@ + $dependencies + * @return array> + */ + protected function check(array $dependencies): array + { + File::put(base_path('package.json'), json_encode(['devDependencies' => $dependencies])); + + Artisan::call('manifest:check', ['--json' => true]); + + return collect(json_decode(Artisan::output(), true))->keyBy('name')->all(); + } + + #[Test] + public function it_declares_the_react_packages_for_the_pages_only(): void + { + $package = app(Manifest::class)->package('stats'); + + $this->assertSame([], $package->npmDependencies(NpmScopes::ASSETS)); + $this->assertSame([], $package->npmPeerDependencies(NpmScopes::ASSETS)); + + $this->assertSame(['@javaabu/forms', '@javaabu/onu', 'recharts'], array_keys($package->npmDependencies(NpmScopes::PAGES))); + $this->assertSame(['react', 'react-dom', '@inertiajs/react'], array_keys($package->npmPeerDependencies(NpmScopes::PAGES))); + } + + #[Test] + public function it_asks_blade_apps_only_for_the_build_tools(): void + { + $rows = $this->check(['vite' => '^7.0', 'laravel-vite-plugin' => '^2.0']); + + $this->assertArrayNotHasKey('recharts', $rows); + + // Only the tools that build the chart styles + $needed = collect($rows)->filter(fn (array $row) => in_array('stats', $row['needed_by']))->keys()->sort()->values()->all(); + + $this->assertSame(['laravel-vite-plugin', 'vite'], $needed); + } + + #[Test] + public function it_asks_inertia_apps_for_what_the_pages_need(): void + { + $rows = $this->check(['@inertiajs/react' => '^2.0']); + + foreach (['@javaabu/forms', '@javaabu/onu', 'recharts', 'react', 'react-dom', '@inertiajs/react'] as $name) { + $this->assertContains('stats', $rows[$name]['needed_by'], $name); + $this->assertContains('pages', $rows[$name]['scopes'], $name); + } + } +} 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/InertiaPagesTest.php b/tests/Feature/Pages/InertiaPagesTest.php new file mode 100644 index 0000000..19da8de --- /dev/null +++ b/tests/Feature/Pages/InertiaPagesTest.php @@ -0,0 +1,117 @@ +set('stats.views', 'inertia'); + $app['config']->set('inertia.testing.ensure_pages_exist', false); + } + + protected function setUp(): void + { + parent::setUp(); + + Route::middleware('web')->group(fn () => Route::stats()); + + $this->registerTestStats(); + $this->allowViewingStats(); + } + + #[Test] + public function it_renders_the_time_series_page_with_the_form_and_the_chart(): void + { + [$ahmed] = $this->seedPayments(); + app()->setLocale('dv'); + + $response = $this->actingAs($ahmed) + ->get('/stats/time-series?metric=payments_count&mode=day&date_range=custom&date_from=2024-07-01&date_to=2024-07-04&compare_to=previous') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('stats::TimeSeries/Index') + ->where('form.method', 'GET') + ->where('form.state.metric', 'payments_count') + ->where('form.components.0.name', 'metric') + ->where('chart.type', 'time-series') + ->where('chart.data.2.payments_count', 3) + ->where('chart.comparison.previousKey', 'previous') + ->where('export.url', url('stats/time-series')) + ->has('export.token') + ->where('strings.title', 'ވަގުތާއެކު ތަފާސް ހިސާބު') + ->etc()); + + $this->writeFixture($response, 'time-series-props.json'); + } + + #[Test] + public function it_renders_the_categorical_page_with_a_pie(): void + { + [$ahmed] = $this->seedPayments(); + + $response = $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() + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('stats::Categorical/Index') + ->where('chart.type', 'pie') + ->where('chart.data.0.name', 'އަޙްމަދު') + ->where('chart.total', 5) + ->etc()); + + $this->writeFixture($response, 'categorical-props.json'); + } + + #[Test] + public function it_sends_the_errors_instead_of_a_chart(): void + { + [$ahmed] = $this->seedPayments(); + + $this->actingAs($ahmed) + ->get('/stats/time-series?metric=unknown') + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('chart', null) + ->where('export', null) + ->has('errors.metric') + ->etc()); + } + + protected function writeFixture(TestResponse $response, string $name): void + { + if (! getenv('UPDATE_FIXTURES')) { + return; + } + + file_put_contents( + __DIR__ . '/../../js/fixtures/' . $name, + json_encode($response->viewData('page')['props'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n", + ); + } +} 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('assertSee('View as table'); + } + + #[Test] + public function it_draws_the_chosen_stat_for_the_chosen_range(): void + { + [$ahmed] = $this->seedPayments(); + + $response = $this->actingAs($ahmed) + ->get('/stats/time-series?metric=payments_count&mode=day&date_range=custom&date_from=2024-07-01&date_to=2024-07-04') + ->assertOk(); + + $response->assertSeeInOrder(['1 Jul 24', '2 Jul 24', '3 Jul 24', '4 Jul 24']); + $response->assertSee('3 Jul 24 · Payments: 3', false); + $response->assertSee('4 Jul 24 · Payments: 2', false); + $response->assertSee('Line chart with 1 series and 4 data points.'); + $response->assertSee('Payments: lowest 0, highest 3.'); + } + + #[Test] + public function it_compares_the_stat_with_the_previous_period(): void + { + [$ahmed] = $this->seedPayments(); + + $this->actingAs($ahmed) + ->get('/stats/time-series?metric=payments_count&mode=day&date_range=custom&date_from=2024-07-01&date_to=2024-07-04&compare_to=previous') + ->assertOk() + ->assertSee('data-slot="period-totals"', false) + ->assertSee('data-slot="period-current-total">5<', false) + ->assertSee('data-slot="period-previous-total">1<', false) + ->assertSee('data-direction="up"', false) + ->assertSee('Up 400%'); + } + + #[Test] + public function it_plots_other_stats_on_the_same_chart(): void + { + [$ahmed] = $this->seedPayments(); + + $this->actingAs($ahmed) + ->get('/stats/time-series?metric=payments_count&with[]=payments_sum&mode=day&date_range=custom&date_from=2024-07-03&date_to=2024-07-04&variant=bar') + ->assertOk() + ->assertSee('data-key="payments_count"', false) + ->assertSee('data-key="payments_sum"', false) + ->assertSee('data-slot="chart-legend"', false) + ->assertSee('Bar chart with 2 series and 2 data points.'); + } + + #[Test] + public function it_filters_the_stat_with_its_filters_from_the_query_string(): void + { + [$ahmed] = $this->seedPayments(); + + $this->actingAs($ahmed) + ->get('/stats/time-series?metric=payments_by_status&mode=day&date_range=custom&date_from=2024-07-03&date_to=2024-07-04&status=pending') + ->assertOk() + ->assertSee('name="status"', false) + ->assertSee('ހާލަތު') + ->assertSee('3 Jul 24 · ފައިސާ ދެއްކުން: 0', false) + ->assertSee('4 Jul 24 · ފައިސާ ދެއްކުން: 2', false); + } + + #[Test] + public function it_shows_the_errors_instead_of_a_chart_for_invalid_input(): void + { + [$ahmed] = $this->seedPayments(); + + $this->actingAs($ahmed) + ->get('/stats/time-series?metric=unknown&date_range=custom') + ->assertOk() + ->assertDontSee('data-slot="chart"', false) + ->assertSee('Fix the errors in the form to see the stat.'); + } + + #[Test] + public function it_forbids_users_who_cannot_view_any_stat(): void + { + Gate::define('view_stats', fn () => false); + + TimeSeriesStats::register(['payments_by_status' => PaymentsByStatusCount::class], false); + + $this->actingAs(User::factory()->create()) + ->get('/stats/time-series') + ->assertForbidden(); + } + + #[Test] + public function it_needs_a_logged_in_user(): void + { + $this->getJson('/stats/time-series')->assertUnauthorized(); + } + + #[Test] + public function it_downloads_the_stat_as_csv(): void + { + [$ahmed] = $this->seedPayments(); + config()->set('app.name', 'test app'); + + $response = $this->actingAs($ahmed) + ->post('/stats/time-series', [ + 'metric' => 'payments_count', + 'mode' => 'day', + 'date_from' => '2024-07-03', + 'date_to' => '2024-07-04', + 'compare_to' => 'previous', + ]) + ->assertOk() + ->assertDownload('Test App Payments 20240703-20240704 20240701-20240702.csv'); + + $csv = $response->streamedContent(); + + $this->assertStringStartsWith("\u{FEFF}", $csv); + $this->assertStringContainsString("Day,\"Date Range\",Count\n", $csv); + $this->assertStringContainsString('2024-07-03,"2024-07-03 00:00 - 2024-07-04 23:59",3', $csv); + $this->assertStringContainsString('2024-07-04,"2024-07-03 00:00 - 2024-07-04 23:59",2', $csv); + } + + #[Test] + public function it_validates_the_export(): void + { + [$ahmed] = $this->seedPayments(); + + $this->actingAs($ahmed) + ->post('/stats/time-series', ['metric' => 'unknown', 'mode' => 'day']) + ->assertSessionHasErrors(['metric', 'date_from', 'date_to']); + } +} diff --git a/tests/Feature/Repositories/CategoricalStatsRepositoryMySqlTest.php b/tests/Feature/Repositories/CategoricalStatsRepositoryMySqlTest.php index c70be06..900c016 100644 --- a/tests/Feature/Repositories/CategoricalStatsRepositoryMySqlTest.php +++ b/tests/Feature/Repositories/CategoricalStatsRepositoryMySqlTest.php @@ -2,17 +2,19 @@ namespace Javaabu\Stats\Tests\Feature\Repositories; +use Illuminate\Foundation\Testing\RefreshDatabase; use Javaabu\Stats\Support\ExactDateRange; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Factories\PaymentFactory; -use Javaabu\Stats\Tests\TestSupport\MySQLRefreshDatabase; use Javaabu\Stats\Tests\TestSupport\Stats\Categorical\PaymentsByUser; +use PHPUnit\Framework\Attributes\Test; class CategoricalStatsRepositoryMySqlTest extends TestCase { - use MySQLRefreshDatabase; + use RefreshDatabase; - public function test_it_can_group_and_order_by_a_category_expression(): void + #[Test] + public function it_can_group_and_order_by_a_category_expression(): void { PaymentFactory::new()->count(2)->create([ 'amount' => 5, diff --git a/tests/Feature/Repositories/CategoricalStatsRepositoryTest.php b/tests/Feature/Repositories/CategoricalStatsRepositoryTest.php index bda1833..43b3686 100644 --- a/tests/Feature/Repositories/CategoricalStatsRepositoryTest.php +++ b/tests/Feature/Repositories/CategoricalStatsRepositoryTest.php @@ -12,12 +12,14 @@ use Javaabu\Stats\Tests\TestSupport\Models\User; use Javaabu\Stats\Tests\TestSupport\Stats\Categorical\PaymentAmountsByUser; use Javaabu\Stats\Tests\TestSupport\Stats\Categorical\PaymentsByUser; +use PHPUnit\Framework\Attributes\Test; class CategoricalStatsRepositoryTest extends TestCase { use RefreshDatabase; - public function test_it_groups_results_by_category_and_applies_dates_and_filters(): void + #[Test] + public function it_groups_results_by_category_and_applies_dates_and_filters(): void { $alice = User::factory()->create(['name' => 'Alice']); $bob = User::factory()->create(['name' => 'Bob']); @@ -36,7 +38,8 @@ public function test_it_groups_results_by_category_and_applies_dates_and_filters ], $stats->resultsToArray($stats->results())); } - public function test_all_mode_includes_categories_without_results(): void + #[Test] + public function it_all_mode_includes_categories_without_results(): void { $alice = User::factory()->create(['name' => 'Alice']); User::factory()->create(['name' => 'Bob']); @@ -49,7 +52,8 @@ public function test_all_mode_includes_categories_without_results(): void $this->assertEquals([2, 0], $result['stats']); } - public function test_specific_mode_only_includes_requested_categories(): void + #[Test] + public function it_specific_mode_only_includes_requested_categories(): void { $alice = User::factory()->create(['name' => 'Alice']); $bob = User::factory()->create(['name' => 'Bob']); @@ -67,7 +71,8 @@ public function test_specific_mode_only_includes_requested_categories(): void $this->assertEquals([2], $result['stats']); } - public function test_it_can_sum_results_by_category(): void + #[Test] + public function it_can_sum_results_by_category(): void { $alice = User::factory()->create(['name' => 'Alice']); PaymentFactory::new()->withUser($alice)->create([ @@ -84,7 +89,8 @@ public function test_it_can_sum_results_by_category(): void $this->assertSame(19.75, $stats->resultsToArray($stats->results())[$alice->id]); } - public function test_it_can_group_and_order_by_a_category_expression(): void + #[Test] + public function it_can_group_and_order_by_a_category_expression(): void { PaymentFactory::new()->count(2)->create([ 'amount' => 5, @@ -109,7 +115,8 @@ public function getCategoryField(): string ], $stats->resultsToArray($stats->results())); } - public function test_it_can_customize_the_grouped_category_field_alias(): void + #[Test] + public function it_can_customize_the_grouped_category_field_alias(): void { $alice = User::factory()->create(['name' => 'Alice']); PaymentFactory::new()->withUser($alice)->count(2)->create(['paid_at' => '2024-07-03']); diff --git a/tests/Feature/Repositories/CountStatsRepositoryTest.php b/tests/Feature/Repositories/CountStatsRepositoryTest.php index cb3890c..b37e517 100644 --- a/tests/Feature/Repositories/CountStatsRepositoryTest.php +++ b/tests/Feature/Repositories/CountStatsRepositoryTest.php @@ -2,18 +2,20 @@ namespace Javaabu\Stats\Tests\Feature\Repositories; +use Illuminate\Foundation\Testing\RefreshDatabase; use Javaabu\Stats\Enums\PresetDateRanges; use Javaabu\Stats\Enums\TimeSeriesModes; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Models\Payment; -use Javaabu\Stats\Tests\TestSupport\MySQLRefreshDatabase; use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\PaymentsCount; +use PHPUnit\Framework\Attributes\Test; class CountStatsRepositoryTest extends TestCase { - use MySQLRefreshDatabase; + use RefreshDatabase; - public function test_it_can_get_can_get_the_hourly_count(): void + #[Test] + public function it_can_get_can_get_the_hourly_count(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -45,11 +47,12 @@ public function test_it_can_get_can_get_the_hourly_count(): void [ 'count' => 2, 'hour' => '2024-07-22 20:00', - ] + ], ], $data); } - public function test_it_can_get_can_get_the_daily_count(): void + #[Test] + public function it_can_get_can_get_the_daily_count(): void { $this->travelTo('2024-07-04'); @@ -83,11 +86,12 @@ public function test_it_can_get_can_get_the_daily_count(): void [ 'count' => 2, 'day' => '2024-07-04', - ] + ], ], $data); } - public function test_it_can_get_can_get_the_weekly_count(): void + #[Test] + public function it_can_get_can_get_the_weekly_count(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -114,16 +118,17 @@ public function test_it_can_get_can_get_the_weekly_count(): void $this->assertEquals([ [ 'count' => 2, - 'week' => '202401', + 'week' => '2023-12-31', ], [ 'count' => 5, - 'week' => '202430', - ] + 'week' => '2024-07-21', + ], ], $data); } - public function test_it_can_get_can_get_the_monthly_count(): void + #[Test] + public function it_can_get_can_get_the_monthly_count(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -150,16 +155,17 @@ public function test_it_can_get_can_get_the_monthly_count(): void $this->assertEquals([ [ 'count' => 2, - 'month' => '2024, 01', + 'month' => '2024-01', ], [ 'count' => 5, - 'month' => '2024, 07', - ] + 'month' => '2024-07', + ], ], $data); } - public function test_it_can_get_can_get_the_yearly_count(): void + #[Test] + public function it_can_get_can_get_the_yearly_count(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -191,11 +197,12 @@ public function test_it_can_get_can_get_the_yearly_count(): void [ 'count' => 5, 'year' => '2024', - ] + ], ], $data); } - public function test_it_can_get_the_total_count(): void + #[Test] + public function it_can_get_the_total_count(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -206,5 +213,4 @@ public function test_it_can_get_the_total_count(): void $this->assertEquals(0, $data); } - } diff --git a/tests/Feature/Repositories/SumStatsRepositoryTest.php b/tests/Feature/Repositories/SumStatsRepositoryTest.php index 94d8c1e..8183700 100644 --- a/tests/Feature/Repositories/SumStatsRepositoryTest.php +++ b/tests/Feature/Repositories/SumStatsRepositoryTest.php @@ -2,18 +2,20 @@ namespace Javaabu\Stats\Tests\Feature\Repositories; +use Illuminate\Foundation\Testing\RefreshDatabase; use Javaabu\Stats\Enums\PresetDateRanges; use Javaabu\Stats\Enums\TimeSeriesModes; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Models\Payment; -use Javaabu\Stats\Tests\TestSupport\MySQLRefreshDatabase; use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\PaymentsSum; +use PHPUnit\Framework\Attributes\Test; class SumStatsRepositoryTest extends TestCase { - use MySQLRefreshDatabase; + use RefreshDatabase; - public function test_it_can_get_can_get_the_hourly_sum(): void + #[Test] + public function it_can_get_can_get_the_hourly_sum(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -47,11 +49,12 @@ public function test_it_can_get_can_get_the_hourly_sum(): void [ 'total' => 10, 'hour' => '2024-07-22 20:00', - ] + ], ], $data); } - public function test_it_can_get_can_get_the_daily_sum(): void + #[Test] + public function it_can_get_can_get_the_daily_sum(): void { $this->travelTo('2024-07-04'); @@ -87,11 +90,12 @@ public function test_it_can_get_can_get_the_daily_sum(): void [ 'total' => 10, 'day' => '2024-07-04', - ] + ], ], $data); } - public function test_it_can_get_can_get_the_weekly_sum(): void + #[Test] + public function it_can_get_can_get_the_weekly_sum(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -120,16 +124,17 @@ public function test_it_can_get_can_get_the_weekly_sum(): void $this->assertEquals([ [ 'total' => 10, - 'week' => '202401', + 'week' => '2023-12-31', ], [ 'total' => 50, - 'week' => '202430', - ] + 'week' => '2024-07-21', + ], ], $data); } - public function test_it_can_get_can_get_the_monthly_sum(): void + #[Test] + public function it_can_get_can_get_the_monthly_sum(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -158,16 +163,17 @@ public function test_it_can_get_can_get_the_monthly_sum(): void $this->assertEquals([ [ 'total' => 10, - 'month' => '2024, 01', + 'month' => '2024-01', ], [ 'total' => 50, - 'month' => '2024, 07', - ] + 'month' => '2024-07', + ], ], $data); } - public function test_it_can_get_can_get_the_yearly_sum(): void + #[Test] + public function it_can_get_can_get_the_yearly_sum(): void { $this->travelTo('2024-07-22 8:06 PM'); @@ -201,8 +207,7 @@ public function test_it_can_get_can_get_the_yearly_sum(): void [ 'total' => 50, 'year' => '2024', - ] + ], ], $data); } - } diff --git a/tests/Feature/Repositories/UserLoginsRepositoryTest.php b/tests/Feature/Repositories/UserLoginsRepositoryTest.php index ef4ab15..356823c 100644 --- a/tests/Feature/Repositories/UserLoginsRepositoryTest.php +++ b/tests/Feature/Repositories/UserLoginsRepositoryTest.php @@ -9,12 +9,18 @@ use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory; use Javaabu\Stats\Tests\TestSupport\Models\User; use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\TestUserLoginsRepository; +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 UserLoginsRepositoryTest extends TestCase { use RefreshDatabase; - public function test_it_can_generate_user_login_stats(): void + #[Test] + public function it_can_generate_user_login_stats(): void { $this->travelTo('2024-07-04'); @@ -52,11 +58,12 @@ public function test_it_can_generate_user_login_stats(): void [ 'logins' => 2, 'day' => '2024-07-04', - ] + ], ], $data); } - public function test_it_can_filter_logins_by_user(): void + #[Test] + public function it_can_filter_logins_by_user(): void { $user_1 = User::factory()->create(); @@ -89,7 +96,7 @@ public function test_it_can_filter_logins_by_user(): void [ 'logins' => 5, 'day' => '2024-07-07', - ] + ], ], $data); } } diff --git a/tests/Feature/Repositories/UserSignupsRepositoryTest.php b/tests/Feature/Repositories/UserSignupsRepositoryTest.php index 46e3cb2..a3535c6 100644 --- a/tests/Feature/Repositories/UserSignupsRepositoryTest.php +++ b/tests/Feature/Repositories/UserSignupsRepositoryTest.php @@ -6,16 +6,16 @@ use Javaabu\Stats\Enums\PresetDateRanges; use Javaabu\Stats\Enums\TimeSeriesModes; use Javaabu\Stats\Tests\TestCase; -use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory; use Javaabu\Stats\Tests\TestSupport\Models\User; -use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\TestUserLoginsRepository; use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\TestUserSignupsRepository; +use PHPUnit\Framework\Attributes\Test; class UserSignupsRepositoryTest extends TestCase { use RefreshDatabase; - public function test_it_can_generate_user_signup_stats(): void + #[Test] + public function it_can_generate_user_signup_stats(): void { $this->travelTo('2024-07-04'); @@ -49,7 +49,7 @@ public function test_it_can_generate_user_signup_stats(): void [ 'signups' => 2, 'day' => '2024-07-04', - ] + ], ], $data); } } diff --git a/tests/Feature/Views/BladeChartsTest.php b/tests/Feature/Views/BladeChartsTest.php new file mode 100644 index 0000000..799ab28 --- /dev/null +++ b/tests/Feature/Views/BladeChartsTest.php @@ -0,0 +1,171 @@ +registerTestStats(); + $this->seedPayments(); + } + + protected function range(): ExactDateRange + { + return new ExactDateRange('2024-07-01', '2024-07-04 23:59:59'); + } + + protected function renderBlade(string $template, array $data = []): string + { + return Blade::render($template, $data); + } + + #[Test] + public function it_draws_a_time_series_as_an_accessible_svg_figure(): void + { + $chart = TimeSeriesChart::make('payments_count')->mode(TimeSeriesModes::DAY)->range($this->range())->area()->id('payments'); + + $html = $this->renderBlade('', ['chart' => $chart]); + + $this->assertStringContainsString('
assertStringContainsString('role="img" aria-labelledby="payments-title" aria-describedby="payments-summary"', $html); + $this->assertStringContainsString('class="stats-chart__area"', $html); + $this->assertStringContainsString('class="stats-chart__line"', $html); + $this->assertStringContainsString('View as table', $html); + $this->assertStringContainsString('3 Jul 24', $html); + $this->assertStringContainsString('Area chart with 1 series and 4 data points. From 1 Jul 24 to 4 Jul 24.', $html); + $this->assertTrue(Manifest::requirements()->has('stats::charts')); + } + + #[Test] + public function it_renders_a_chart_echoed_directly(): void + { + $html = $this->renderBlade('{{ $chart }}', ['chart' => TimeSeriesChart::make('payments_count')->range($this->range())->bar()]); + + $this->assertStringContainsString('class="stats-chart__bar"', $html); + $this->assertStringContainsString('data-chart-variant="bar"', $html); + } + + #[Test] + public function it_mirrors_charts_and_translates_them_in_dhivehi(): void + { + app()->setLocale('dv'); + + $chart = TimeSeriesChart::make('payments_count')->mode(TimeSeriesModes::MONTH)->range(new ExactDateRange('2024-06-01', '2024-07-31 23:59:59'))->compare(); + + $ltr = $this->renderBlade('', ['chart' => $chart]); + $rtl = $this->renderBlade('', ['chart' => $chart]); + + $this->assertNotEquals($ltr, $rtl); + $this->assertStringContainsString('ޖޫން', $rtl); + $this->assertStringContainsString('text-anchor="start" class="stats-chart__tick"', $rtl); + $this->assertStringContainsString('text-anchor="end" class="stats-chart__tick"', $ltr); + $this->assertStringContainsString('stroke-dasharray="5 4"', $rtl); + } + + #[Test] + public function it_draws_categories_horizontally_and_shows_a_legend_for_several_stats(): void + { + $chart = CategoryChart::make()->stats(['payments_by_user', 'payment_amounts_by_user'])->range($this->range())->horizontal(); + + $html = $this->renderBlade('', ['chart' => $chart]); + + $this->assertStringContainsString('data-slot="chart-legend"', $html); + $this->assertSame(4, substr_count($html, 'class="stats-chart__bar"')); + $this->assertStringContainsString('>User', $html); + } + + #[Test] + public function it_draws_a_donut_with_its_total_in_the_middle(): void + { + $html = $this->renderBlade('', ['chart' => PieChart::make('payments_by_user')->range($this->range())->donut()]); + + $this->assertStringContainsString('data-chart-variant="donut"', $html); + $this->assertSame(2, substr_count($html, 'class="stats-chart__slice"')); + $this->assertStringContainsString('
5', $html); + $this->assertStringContainsString('Total', $html); + $this->assertStringContainsString('dir="ltr">60%', $html); + } + + #[Test] + public function it_draws_a_sparkline_with_its_summary_as_the_name(): void + { + $html = $this->renderBlade('', ['chart' => Sparkline::make('payments_count')->mode(TimeSeriesModes::DAY)->range($this->range())->area()]); + + $this->assertStringContainsString('role="img" aria-label="Payments: 4 values, from 0 to 2. Lowest 0, highest 3."', $html); + $this->assertStringContainsString('class="stats-sparkline__area"', $html); + } + + #[Test] + public function it_draws_stat_tiles_with_the_change(): void + { + $tile = StatTile::make('payments_count')->range($this->range())->compare()->sparkline(TimeSeriesModes::DAY)->href('/payments')->footer('ހަތަރު ދުވަސް'); + + $html = $this->renderBlade('', ['tile' => $tile]); + + $this->assertStringContainsString('data-slot="stat-tiles" data-columns="2"', $html); + $this->assertStringContainsString('assertStringContainsString('href="/payments"', $html); + $this->assertStringContainsString('data-slot="stat-tile-value" dir="auto">5', $html); + $this->assertStringContainsString('data-direction="up" data-sentiment="positive"', $html); + $this->assertStringContainsString('Up 400%', $html); + $this->assertStringContainsString('vs previous period', $html); + $this->assertStringContainsString('ހަތަރު ދުވަސް', $html); + } + + #[Test] + public function it_shows_falls_as_good_when_lower_is_better(): void + { + $tile = StatTile::make('payments_count')->range(new ExactDateRange('2024-06-29', '2024-06-30 23:59:59'))->compare(new ExactDateRange('2024-07-03', '2024-07-04 23:59:59'))->positiveIsGood(false); + + $html = $this->renderBlade('', ['tile' => $tile]); + + $this->assertStringContainsString('data-direction="down" data-sentiment="positive"', $html); + $this->assertStringContainsString('Down 80%', $html); + } + + #[Test] + public function it_says_when_there_is_no_data(): void + { + $html = $this->renderBlade('', ['chart' => PieChart::make('payments_by_user')->range(new ExactDateRange('2020-01-01', '2020-01-02'))]); + + $this->assertStringContainsString('data-slot="chart-empty">No data to show

', $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/TestCase.php b/tests/TestCase.php index dd8a39c..96b46d7 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,77 +2,118 @@ namespace Javaabu\Stats\Tests; -use Illuminate\Foundation\Testing\RefreshDatabaseState; -use Illuminate\Support\Facades\Artisan; +use Carbon\CarbonImmutable; +use Illuminate\Support\Facades\Date; +use Javaabu\Filters\FiltersServiceProvider; use Javaabu\Forms\FormsServiceProvider; use Javaabu\GeneratorHelpers\Testing\InteractsWithTestFiles; use Javaabu\GeneratorHelpers\Testing\InteractsWithTestStubs; -use Javaabu\Helpers\HelpersServiceProvider; -use Javaabu\Settings\SettingsServiceProvider; -use Javaabu\Settings\Testing\FakesSettings; -use Javaabu\Stats\Tests\TestSupport\MySQLRefreshDatabaseState; -use Maatwebsite\Excel\ExcelServiceProvider; -use Orchestra\Testbench\TestCase as BaseTestCase; +use Javaabu\Manifest\ManifestServiceProvider; +use Javaabu\Permissions\PermissionsServiceProvider; +use Javaabu\Stats\CategoricalStats; +use Javaabu\Stats\Facades\Stats; use Javaabu\Stats\StatsServiceProvider; +use Javaabu\Stats\Tests\TestSupport\Models\User; use Javaabu\Stats\Tests\TestSupport\Providers\TestServiceProvider; +use Javaabu\Stats\TimeSeriesStats; +use Javaabu\Stencil\StencilServiceProvider; +use Orchestra\Testbench\TestCase as BaseTestCase; use Spatie\Activitylog\ActivitylogServiceProvider; +use Spatie\Permission\PermissionServiceProvider; +/** + * Runs on SQLite in memory, or on the database in DB_CONNECTION (mysql, mariadb, pgsql). + * STATS_IMMUTABLE_DATES=1 runs every test with Date::use(CarbonImmutable::class). + */ abstract class TestCase extends BaseTestCase { - use FakesSettings; - use InteractsWithTestStubs; use InteractsWithTestFiles; + use InteractsWithTestStubs; - public function setUp(): void - { - if (isset($_ENV['DB_CONNECTION']) || isset($_ENV['DB_DATABASE'])) { - if (! $this->shouldUseMysql()) { - - if (MySQLRefreshDatabaseState::$driver_switched) { - RefreshDatabaseState::$migrated = false; - } - - MySQLRefreshDatabaseState::$driver_switched = false; + /** + * Use CarbonImmutable for now(), today() and model dates, as Laravel's starter kits do + * (Date::use(CarbonImmutable::class)). STATS_IMMUTABLE_DATES=1 turns it on for every test. + */ + protected bool $immutable_dates = false; - $_ENV['DB_CONNECTION'] = 'sqlite'; - $_ENV['DB_DATABASE'] = ':memory:'; - } + protected function setUp(): void + { + // Before the app boots, so the service providers see the app's date class + if ($this->immutable_dates || filter_var(getenv('STATS_IMMUTABLE_DATES'), FILTER_VALIDATE_BOOL)) { + Date::use(CarbonImmutable::class); } parent::setUp(); - $this->loadTestStubsFrom( __DIR__ . '/TestSupport/stubs'); + $this->loadTestStubsFrom(__DIR__ . '/TestSupport/stubs'); + } + + protected function tearDown(): void + { + TimeSeriesStats::register([], false); + CategoricalStats::register([], false); + Stats::clearWarmables(); + + parent::tearDown(); - $this->app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + Date::useDefault(); + } - $this->app['config']->set('session.serialization', 'php'); + protected function defineEnvironment($app): void + { + $app['config']->set('app.key', 'base64:yWa/ByhLC/GUvfToOuaPD7zDwB64qkc/QkaQOrT5IpE='); + $app['config']->set('session.serialization', 'php'); + $app['config']->set('stats.layout', 'test::layouts.admin'); + $app['config']->set('stats.default_stats', false); + $app['config']->set('auth.providers.users.model', User::class); + $app['config']->set('cache.default', 'array'); + + $connection = env('DB_CONNECTION', 'sqlite'); + + if ($connection !== 'sqlite') { + $app['config']->set('database.default', $connection); + $app['config']->set("database.connections.{$connection}.database", env('DB_DATABASE', 'stats_test')); + $app['config']->set("database.connections.{$connection}.username", env('DB_USERNAME', 'root')); + $app['config']->set("database.connections.{$connection}.password", env('DB_PASSWORD', '')); + $app['config']->set("database.connections.{$connection}.host", env('DB_HOST', '127.0.0.1')); + $app['config']->set("database.connections.{$connection}.port", env('DB_PORT', $connection === 'pgsql' ? 5432 : 3306)); + } + } - if (empty(glob($this->app->databasePath('migrations/*_create_activity_log_table.php')))) { - Artisan::call('vendor:publish', [ - '--provider' => 'Spatie\\Activitylog\\ActivitylogServiceProvider', - '--tag' => 'activitylog-migrations', - ]); + protected function getPackageProviders($app): array + { + $providers = [ + PermissionServiceProvider::class, + PermissionsServiceProvider::class, + ManifestServiceProvider::class, + FormsServiceProvider::class, + StencilServiceProvider::class, + FiltersServiceProvider::class, + ]; - Artisan::call('migrate'); + if (class_exists(ActivitylogServiceProvider::class)) { + $providers[] = ActivitylogServiceProvider::class; } + return [ + ...$providers, + StatsServiceProvider::class, + TestServiceProvider::class, + ]; } - public function shouldUseMysql(): bool + /** + * @param array $args + */ + protected function addQueryArg(array $args, string $url): string { - return property_exists($this, 'use_mysql') ? $this->use_mysql : false; + return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($args); } - protected function getPackageProviders($app) + protected function getPackageAliases($app): array { return [ - SettingsServiceProvider::class, - HelpersServiceProvider::class, - ActivitylogServiceProvider::class, - ExcelServiceProvider::class, - FormsServiceProvider::class, - StatsServiceProvider::class, - TestServiceProvider::class + 'Stats' => Stats::class, ]; } } diff --git a/tests/TestSupport/Factories/ActivityFactory.php b/tests/TestSupport/Factories/ActivityFactory.php index 91cd582..cc3929c 100644 --- a/tests/TestSupport/Factories/ActivityFactory.php +++ b/tests/TestSupport/Factories/ActivityFactory.php @@ -34,7 +34,7 @@ public function withUser(User|int|null $user = null): ActivityFactory return $random_user; }), - 'causer_type' => 'user' + 'causer_type' => 'user', ]; }); } diff --git a/tests/TestSupport/Factories/PaymentFactory.php b/tests/TestSupport/Factories/PaymentFactory.php index 4265064..6c933df 100644 --- a/tests/TestSupport/Factories/PaymentFactory.php +++ b/tests/TestSupport/Factories/PaymentFactory.php @@ -3,6 +3,7 @@ namespace Javaabu\Stats\Tests\TestSupport\Factories; use Illuminate\Database\Eloquent\Factories\Factory; +use Javaabu\Stats\Tests\TestSupport\Enums\PaymentStatus; use Javaabu\Stats\Tests\TestSupport\Models\Payment; use Javaabu\Stats\Tests\TestSupport\Models\User; @@ -18,6 +19,11 @@ public function definition(): array ]; } + public function status(PaymentStatus $status): PaymentFactory + { + return $this->state(fn () => ['status' => $status->value]); + } + public function withUser(User|int|null $user = null): PaymentFactory { return $this->state(function (array $attributes) use ($user) { diff --git a/tests/TestSupport/Http/IslandStatsController.php b/tests/TestSupport/Http/IslandStatsController.php new file mode 100644 index 0000000..924fc2f --- /dev/null +++ b/tests/TestSupport/Http/IslandStatsController.php @@ -0,0 +1,17 @@ + (int) $request->query('owner', 0)]; + } +} diff --git a/tests/TestSupport/Models/Admin.php b/tests/TestSupport/Models/Admin.php new file mode 100644 index 0000000..f98f587 --- /dev/null +++ b/tests/TestSupport/Models/Admin.php @@ -0,0 +1,11 @@ +where('email', 'like', '%'.$search.'%'); + return $query->whereLike('email', '%' . $search . '%'); } } diff --git a/tests/TestSupport/Models/CategoryProviderUser.php b/tests/TestSupport/Models/CategoryProviderUser.php index fa259a9..5c7e5bc 100644 --- a/tests/TestSupport/Models/CategoryProviderUser.php +++ b/tests/TestSupport/Models/CategoryProviderUser.php @@ -23,6 +23,6 @@ public function getCategoricalStatsCategoryIdTitle(): string public function scopeCategoricalStatsSearch($query, $search) { - return $query->where('email', 'like', '%'.$search.'%'); + return $query->whereLike('email', '%' . $search . '%'); } } diff --git a/tests/TestSupport/Models/Payment.php b/tests/TestSupport/Models/Payment.php index 77080db..6a3cda7 100644 --- a/tests/TestSupport/Models/Payment.php +++ b/tests/TestSupport/Models/Payment.php @@ -3,10 +3,10 @@ namespace Javaabu\Stats\Tests\TestSupport\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Javaabu\Stats\Tests\TestSupport\Factories\PaymentFactory; class Payment extends Model @@ -15,12 +15,12 @@ class Payment extends Model use SoftDeletes; protected $casts = [ - 'paid_at' => 'datetime' + 'paid_at' => 'datetime', ]; protected static function newFactory() { - return new PaymentFactory(); + return new PaymentFactory; } public function setPaidAtAttribute($value) diff --git a/tests/TestSupport/Models/User.php b/tests/TestSupport/Models/User.php index 11e226f..0178adb 100644 --- a/tests/TestSupport/Models/User.php +++ b/tests/TestSupport/Models/User.php @@ -2,26 +2,24 @@ namespace Javaabu\Stats\Tests\TestSupport\Models; -use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Javaabu\Stats\Tests\TestSupport\Factories\UserFactory; -use Spatie\Activitylog\Traits\CausesActivity; class User extends Authenticatable { use HasFactory; - use CausesActivity; use SoftDeletes; protected static function newFactory() { - return new UserFactory(); + return new UserFactory; } public function scopeSearch($query, $search) { - return $query->where('name', 'like', '%' . $search . '%'); + return $query->whereLike('name', '%' . $search . '%'); } public function getAdminLinkNameAttribute(): string @@ -31,7 +29,7 @@ public function getAdminLinkNameAttribute(): string public function scopeCategoricalStatsSearch($query, $search) { - return $query->where('name', 'like', '%' . $search . '%'); + return $query->whereLike('name', '%' . $search . '%'); } public function getMorphClass() diff --git a/tests/TestSupport/MySQLRefreshDatabase.php b/tests/TestSupport/MySQLRefreshDatabase.php deleted file mode 100644 index b07666c..0000000 --- a/tests/TestSupport/MySQLRefreshDatabase.php +++ /dev/null @@ -1,33 +0,0 @@ -setupMySql(); - - parent::setUp(); - } - - protected function setupMySql(): void - { - if (! MySQLRefreshDatabaseState::$migrated) { - RefreshDatabaseState::$migrated = false; - } - - MySQLRefreshDatabaseState::$migrated = true; - MySQLRefreshDatabaseState::$driver_switched = true; - - $_ENV['DB_CONNECTION'] = 'mysql'; - $_ENV['DB_DATABASE'] = 'stats_test'; - } -} diff --git a/tests/TestSupport/MySQLRefreshDatabaseState.php b/tests/TestSupport/MySQLRefreshDatabaseState.php deleted file mode 100644 index 195df0e..0000000 --- a/tests/TestSupport/MySQLRefreshDatabaseState.php +++ /dev/null @@ -1,12 +0,0 @@ -loadViewsFrom(__DIR__ . '/../views', 'test'); + + // The Inertia root view, `app` + $this->app['view']->addLocation(__DIR__ . '/../views'); } /** * Register the application services. */ - public function register() - { - - } + public function register() {} } diff --git a/tests/TestSupport/Stats/TimeSeries/PaymentsByStatusCount.php b/tests/TestSupport/Stats/TimeSeries/PaymentsByStatusCount.php new file mode 100644 index 0000000..28c012f --- /dev/null +++ b/tests/TestSupport/Stats/TimeSeries/PaymentsByStatusCount.php @@ -0,0 +1,49 @@ +label('ހާލަތު')->options(PaymentStatus::class)), + ]; + } + + public function query(): Builder + { + return Payment::query(); + } + + public function getTable(): string + { + return 'payments'; + } + + public function getDateFieldName(): string + { + return 'paid_at'; + } + + public function getAggregateFieldName(): string + { + return 'count'; + } + + public function getName(): string + { + return 'ފައިސާ ދެއްކުން'; + } +} diff --git a/tests/TestSupport/Stats/TimeSeries/PaymentsCount.php b/tests/TestSupport/Stats/TimeSeries/PaymentsCount.php index f6eb559..a780cc0 100644 --- a/tests/TestSupport/Stats/TimeSeries/PaymentsCount.php +++ b/tests/TestSupport/Stats/TimeSeries/PaymentsCount.php @@ -1,4 +1,5 @@ bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->uuid('batch_uuid')->nullable(); + $table->timestamps(); + $table->index('log_name'); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + } +}; diff --git a/tests/TestSupport/database/2024_07_22_223412_create_payments_table.php b/tests/TestSupport/database/2024_07_22_223412_create_payments_table.php index 332394c..245589c 100644 --- a/tests/TestSupport/database/2024_07_22_223412_create_payments_table.php +++ b/tests/TestSupport/database/2024_07_22_223412_create_payments_table.php @@ -1,16 +1,18 @@ id(); $table->foreignId('user_id')->nullable()->constrained('users'); $table->decimal('amount', 14, 2); + $table->string('status')->default('paid'); $table->dateTime('paid_at')->nullable()->index(); $table->timestamps(); $table->softDeletes(); 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/Unit/CategoricalStatsTest.php b/tests/Unit/CategoricalStatsTest.php index 7a84dd8..d35a383 100644 --- a/tests/Unit/CategoricalStatsTest.php +++ b/tests/Unit/CategoricalStatsTest.php @@ -8,10 +8,12 @@ use Javaabu\Stats\Formatters\Categorical\DefaultCategoricalStatsFormatter; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Stats\Categorical\PaymentsByUser; +use PHPUnit\Framework\Attributes\Test; class CategoricalStatsTest extends TestCase { - public function test_it_can_register_and_create_categorical_stats(): void + #[Test] + public function it_can_register_and_create_categorical_stats(): void { CategoricalStats::register([ 'payments_by_user' => PaymentsByUser::class, @@ -24,7 +26,8 @@ public function test_it_can_register_and_create_categorical_stats(): void $this->assertEquals(PresetDateRanges::LAST_7_DAYS, $stats->getDateRange()); } - public function test_it_only_lists_metrics_that_support_the_current_filters(): void + #[Test] + public function it_only_lists_metrics_that_support_the_current_filters(): void { CategoricalStats::register([ 'payments_by_user' => PaymentsByUser::class, @@ -37,7 +40,8 @@ public function test_it_only_lists_metrics_that_support_the_current_filters(): v $this->assertEmpty(CategoricalStats::allowedMetrics(['unknown'])); } - public function test_it_can_register_and_create_a_formatter(): void + #[Test] + public function it_can_register_and_create_a_formatter(): void { CategoricalStats::registerFormatters([ 'default' => DefaultCategoricalStatsFormatter::class, diff --git a/tests/Unit/CategoryProviders/CategoryProviderTest.php b/tests/Unit/CategoryProviders/CategoryProviderTest.php index 7e0567d..a2d7c67 100644 --- a/tests/Unit/CategoryProviders/CategoryProviderTest.php +++ b/tests/Unit/CategoryProviders/CategoryProviderTest.php @@ -15,12 +15,14 @@ use Javaabu\Stats\Tests\TestSupport\Models\CategoricalSearchUser; use Javaabu\Stats\Tests\TestSupport\Models\CategoryProviderUser; use Javaabu\Stats\Tests\TestSupport\Models\User; +use PHPUnit\Framework\Attributes\Test; class CategoryProviderTest extends TestCase { use RefreshDatabase; - public function test_it_can_create_a_provider_from_an_array(): void + #[Test] + public function it_can_create_a_provider_from_an_array(): void { $provider = CategoryProviderFactory::make([ 10 => 'North', @@ -38,7 +40,8 @@ public function test_it_can_create_a_provider_from_an_array(): void ], $provider->searchCategoricalStatsItems('nor')->getCollection()->all()); } - public function test_it_can_create_a_provider_from_a_collection(): void + #[Test] + public function it_can_create_a_provider_from_a_collection(): void { $provider = CategoryProviderFactory::make(collect([ ['id' => 'a', 'label' => 'Alpha'], @@ -51,7 +54,8 @@ public function test_it_can_create_a_provider_from_a_collection(): void ], $provider->getCategoricalStatsItems()->all()); } - public function test_it_can_create_a_provider_from_an_enum(): void + #[Test] + public function it_can_create_a_provider_from_an_enum(): void { $provider = CategoryProviderFactory::make(PaymentStatus::class); @@ -63,7 +67,8 @@ public function test_it_can_create_a_provider_from_an_enum(): void $this->assertSame('Payment Status ID', $provider->getCategoricalStatsCategoryIdTitle()); } - public function test_it_can_create_a_searchable_provider_from_an_eloquent_model(): void + #[Test] + public function it_can_create_a_searchable_provider_from_an_eloquent_model(): void { User::factory()->create(['name' => 'Alice']); User::factory()->create(['name' => 'Bob']); @@ -75,7 +80,8 @@ public function test_it_can_create_a_searchable_provider_from_an_eloquent_model( $this->assertSame('User ID', $provider->getCategoricalStatsCategoryIdTitle()); } - public function test_the_categorical_stats_search_scope_takes_precedence(): void + #[Test] + public function it_the_categorical_stats_search_scope_takes_precedence(): void { User::factory()->create(['name' => 'Alice', 'email' => 'special@example.com']); User::factory()->create(['name' => 'Special Name', 'email' => 'other@example.com']); @@ -85,7 +91,8 @@ public function test_the_categorical_stats_search_scope_takes_precedence(): void $this->assertEquals(['Alice'], $provider->searchCategoricalStatsItems('special')->getCollection()->pluck('label')->all()); } - public function test_the_categorical_stats_label_field_takes_precedence_over_admin_link_name(): void + #[Test] + public function it_the_categorical_stats_label_field_takes_precedence_over_admin_link_name(): void { User::factory()->create(['name' => 'Alice', 'email' => 'alice@example.com']); @@ -107,7 +114,8 @@ public function getCategoricalStatsLabelField(): string ); } - public function test_an_eloquent_model_can_use_the_prefixed_provider_trait(): void + #[Test] + public function it_an_eloquent_model_can_use_the_prefixed_provider_trait(): void { User::factory()->create(['name' => 'Alice', 'email' => 'special@example.com']); User::factory()->create(['name' => 'Special Name', 'email' => 'other@example.com']); @@ -120,7 +128,8 @@ public function test_an_eloquent_model_can_use_the_prefixed_provider_trait(): vo $this->assertSame('Account Number', $provider->getCategoricalStatsCategoryIdTitle()); } - public function test_it_paginates_array_providers(): void + #[Test] + public function it_paginates_array_providers(): void { $provider = CategoryProviderFactory::make([ 10 => 'North', @@ -131,7 +140,8 @@ public function test_it_paginates_array_providers(): void $this->assertCategoryProviderPage($provider, 20); } - public function test_it_paginates_collection_providers(): void + #[Test] + public function it_paginates_collection_providers(): void { $provider = CategoryProviderFactory::make(collect([ ['id' => 'a', 'label' => 'Alpha'], @@ -142,14 +152,16 @@ public function test_it_paginates_collection_providers(): void $this->assertCategoryProviderPage($provider, 'b'); } - public function test_it_paginates_enum_providers(): void + #[Test] + public function it_paginates_enum_providers(): void { $provider = CategoryProviderFactory::make(PaymentStatus::class); $this->assertCategoryProviderPage($provider, 'paid', 2); } - public function test_it_paginates_eloquent_providers(): void + #[Test] + public function it_paginates_eloquent_providers(): void { User::factory()->create(['name' => 'Alice']); $bob = User::factory()->create(['name' => 'Bob']); @@ -159,7 +171,8 @@ public function test_it_paginates_eloquent_providers(): void $this->assertCategoryProviderPage($provider, $bob->id); } - public function test_it_can_sort_array_and_collection_providers_by_field_and_direction(): void + #[Test] + public function it_can_sort_array_and_collection_providers_by_field_and_direction(): void { $array_provider = new ArrayCategoryProvider([ 10 => 'North', @@ -188,7 +201,8 @@ public function test_it_can_sort_array_and_collection_providers_by_field_and_dir ); } - public function test_it_can_sort_in_memory_providers_using_a_callback(): void + #[Test] + public function it_can_sort_in_memory_providers_using_a_callback(): void { $provider = new ArrayCategoryProvider([ 10 => 'North', @@ -206,7 +220,8 @@ public function test_it_can_sort_in_memory_providers_using_a_callback(): void ); } - public function test_it_can_sort_enum_providers(): void + #[Test] + public function it_can_sort_enum_providers(): void { $provider = new EnumCategoryProvider(PaymentStatus::class); @@ -219,7 +234,8 @@ public function test_it_can_sort_enum_providers(): void ); } - public function test_it_can_sort_eloquent_providers_by_field_and_direction(): void + #[Test] + public function it_can_sort_eloquent_providers_by_field_and_direction(): void { User::factory()->create(['name' => 'Alice']); User::factory()->create(['name' => 'Charlie']); @@ -236,7 +252,8 @@ public function test_it_can_sort_eloquent_providers_by_field_and_direction(): vo ); } - public function test_it_can_sort_an_eloquent_provider_using_model_methods(): void + #[Test] + public function it_can_sort_an_eloquent_provider_using_model_methods(): void { User::factory()->create(['name' => 'Alice']); User::factory()->create(['name' => 'Charlie']); @@ -265,7 +282,8 @@ public function getCategoricalStatsSortDirection(): string ); } - public function test_the_categorical_stats_sort_scope_takes_precedence_over_model_sort_methods(): void + #[Test] + public function it_the_categorical_stats_sort_scope_takes_precedence_over_model_sort_methods(): void { User::factory()->create(['name' => 'Alice']); User::factory()->create(['name' => 'Charlie']); @@ -299,7 +317,8 @@ public function scopeCategoricalStatsSort($query) ); } - public function test_it_preserves_sorting_defined_on_an_eloquent_query(): void + #[Test] + public function it_preserves_sorting_defined_on_an_eloquent_query(): void { User::factory()->create(['name' => 'Alice']); User::factory()->create(['name' => 'Charlie']); @@ -317,7 +336,8 @@ public function test_it_preserves_sorting_defined_on_an_eloquent_query(): void ); } - public function test_it_can_sort_eloquent_providers_using_a_query_callback(): void + #[Test] + public function it_can_sort_eloquent_providers_using_a_query_callback(): void { User::factory()->create(['name' => 'Alice']); User::factory()->create(['name' => 'Charlie']); diff --git a/tests/Unit/Concerns/HasDateRangeTest.php b/tests/Unit/Concerns/HasDateRangeTest.php index 96dbafc..7adb289 100644 --- a/tests/Unit/Concerns/HasDateRangeTest.php +++ b/tests/Unit/Concerns/HasDateRangeTest.php @@ -7,22 +7,26 @@ use Javaabu\Stats\Enums\TimeSeriesModes; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory; -use Javaabu\Stats\Tests\TestSupport\Models\User; use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository; -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 HasDateRangeTest extends TestCase { use RefreshDatabase; - public function setUp(): void + protected function setUp(): void { parent::setUp(); $this->travelTo('2024-07-13 12:27 PM'); } - public function test_it_can_get_the_date_from_and_date_to(): void + #[Test] + public function it_can_get_the_date_from_and_date_to(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); @@ -30,23 +34,26 @@ public function test_it_can_get_the_date_from_and_date_to(): void $this->assertEquals('2024-07-13 23:59:59', $stat->getDateTo()->toDateTimeString()); } - public function test_it_can_get_the_date_range(): void + #[Test] + public function it_can_get_the_date_range(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); $this->assertEquals(PresetDateRanges::LAST_7_DAYS, $stat->getDateRange()); } - public function test_it_can_format_the_date_range(): void + #[Test] + public function it_can_format_the_date_range(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); $this->assertEquals('2024-07-07 00:00 - 2024-07-13 23:59', $stat->formattedDateRange()); } - public function test_it_can_set_date_from(): void + #[Test] + public function it_can_set_date_from(): void { - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $stat->setDateFrom('2024-07-05 00:00:00'); @@ -54,9 +61,10 @@ public function test_it_can_set_date_from(): void $this->assertEquals('2024-07-05 00:00:00', $stat->getDateRange()->getDateFrom()->toDateTimeString()); } - public function test_it_can_set_date_to(): void + #[Test] + public function it_can_set_date_to(): void { - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $stat->setDateTo('2024-07-10 00:00:00'); @@ -64,14 +72,16 @@ public function test_it_can_set_date_to(): void $this->assertEquals('2024-07-10 00:00:00', $stat->getDateRange()->getDateTo()->toDateTimeString()); } - public function test_it_can_get_date_field(): void + #[Test] + public function it_can_get_date_field(): void { - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $this->assertEquals('activity_log.created_at', $stat->getDateField()); } - public function test_it_can_get_max_date_and_min_date(): void + #[Test] + public function it_can_get_max_date_and_min_date(): void { ActivityFactory::new() ->logout() @@ -87,15 +97,16 @@ public function test_it_can_get_max_date_and_min_date(): void 'created_at' => '2024-07-08 00:00:00', ]); - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $this->assertEquals('2024-07-04 00:00:00', $stat->getMinDate()->toDateTimeString()); $this->assertEquals('2024-07-08 00:00:00', $stat->getMaxDate()->toDateTimeString()); } - public function test_it_can_set_the_date_range(): void + #[Test] + public function it_can_set_the_date_range(): void { - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $this->assertEquals(PresetDateRanges::THIS_YEAR, $stat->getDateRange()); @@ -106,7 +117,8 @@ public function test_it_can_set_the_date_range(): void $this->assertEquals('2024-07-13 23:59:59', $stat->getDateTo()->toDateTimeString()); } - public function test_it_can_apply_date_filters(): void + #[Test] + public function it_can_apply_date_filters(): void { ActivityFactory::new() ->logout() @@ -129,7 +141,8 @@ public function test_it_can_apply_date_filters(): void $this->assertEquals('2024-07-07 00:00:00', $first_log->created_at->toDateTimeString()); } - public function test_it_can_query_without_date_filters(): void + #[Test] + public function it_can_query_without_date_filters(): void { ActivityFactory::new() ->logout() @@ -152,7 +165,8 @@ public function test_it_can_query_without_date_filters(): void $this->assertEquals('2024-07-04 00:00:00', $first_log->created_at->toDateTimeString()); } - public function test_it_can_get_the_interval_for_the_given_mode(): void + #[Test] + public function it_can_get_the_interval_for_the_given_mode(): void { $stat = new UserLogoutsRepository(PresetDateRanges::TODAY); $this->assertEquals(0, $stat->interval(TimeSeriesModes::DAY)); @@ -178,5 +192,4 @@ public function test_it_can_get_the_interval_for_the_given_mode(): void $stat = new UserLogoutsRepository(PresetDateRanges::LIFETIME); $this->assertEquals(4, $stat->interval(TimeSeriesModes::YEAR)); } - } diff --git a/tests/Unit/Concerns/HasFiltersTest.php b/tests/Unit/Concerns/HasFiltersTest.php index 5821766..47ff937 100644 --- a/tests/Unit/Concerns/HasFiltersTest.php +++ b/tests/Unit/Concerns/HasFiltersTest.php @@ -10,40 +10,50 @@ use Javaabu\Stats\Tests\TestSupport\Factories\ActivityFactory; use Javaabu\Stats\Tests\TestSupport\Models\User; 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 HasFiltersTest extends TestCase { use RefreshDatabase; - public function test_it_can_get_filters(): void + #[Test] + public function it_can_get_filters(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS, ['user' => 1]); $this->assertEquals(['user' => 1], $stat->getFilters()); } - public function test_it_can_get_single_filter_value(): void + #[Test] + public function it_can_get_single_filter_value(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS, ['user' => 1]); $this->assertEquals(1, $stat->getFilter('user')); } - public function test_it_can_get_non_existing_filter_value(): void + #[Test] + public function it_can_get_non_existing_filter_value(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); $this->assertNull($stat->getFilter('user')); } - public function test_it_can_get_default_value_for_non_existing_filter_value(): void + #[Test] + public function it_can_get_default_value_for_non_existing_filter_value(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); $this->assertEquals(2, $stat->getFilter('user', 2)); } - public function test_it_can_get_filtered_query(): void + #[Test] + public function it_can_get_filtered_query(): void { $user_1 = User::factory()->create(); @@ -63,14 +73,15 @@ public function test_it_can_get_filtered_query(): void 'created_at' => '2024-07-07 00:00:00', ]); - $stat = new UserLogoutsRepository(PresetDateRanges::LIFETIME, ['user' => 2]); + $stat = new UserLogoutsRepository(PresetDateRanges::LIFETIME, ['user' => $user_2->id]); $first_log = $stat->filteredQuery()->first(); $this->assertEquals('2024-07-07 00:00:00', $first_log->created_at->toDateTimeString()); } - public function test_it_can_set_filters(): void + #[Test] + public function it_can_set_filters(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); @@ -81,14 +92,16 @@ public function test_it_can_set_filters(): void $this->assertEquals(1, $stat->getFilter('user')); } - public function test_it_does_not_allow_setting_disallowed_filters(): void + #[Test] + public function it_does_not_allow_setting_disallowed_filters(): void { $this->expectException(InvalidFiltersException::class); $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS, ['user' => 1, 'admin' => 2]); } - public function test_it_can_check_allowed_filters_for_associative_arrays(): void + #[Test] + public function it_can_check_allowed_filters_for_associative_arrays(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); @@ -96,7 +109,8 @@ public function test_it_can_check_allowed_filters_for_associative_arrays(): void $this->assertFalse($stat->ensureAllFiltersAllowed(['user' => 1, 'admin' => 2])); } - public function test_it_can_check_allowed_filters_for_list_arrays(): void + #[Test] + public function it_can_check_allowed_filters_for_list_arrays(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); @@ -104,14 +118,16 @@ public function test_it_can_check_allowed_filters_for_list_arrays(): void $this->assertFalse($stat->ensureAllFiltersAllowed(['user', 'admin'])); } - public function test_it_can_get_the_allowed_filters(): void + #[Test] + public function it_can_get_the_allowed_filters(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); $this->assertEquals([StatsFilter::exact('user', 'causer_id')], $stat->allowedFilters()); } - public function test_it_can_apply_filters(): void + #[Test] + public function it_can_apply_filters(): void { $user_1 = User::factory()->create(); @@ -131,20 +147,19 @@ public function test_it_can_apply_filters(): void 'created_at' => '2024-07-07 00:00:00', ]); - $stat = new UserLogoutsRepository(PresetDateRanges::LIFETIME, ['user' => 1]); + $stat = new UserLogoutsRepository(PresetDateRanges::LIFETIME, ['user' => $user_1->id]); $first_log = $stat->applyFilters($stat->query())->first(); $this->assertEquals('2024-07-04 00:00:00', $first_log->created_at->toDateTimeString()); } - public function test_it_can_check_for_allowed_filters(): void + #[Test] + public function it_can_check_for_allowed_filters(): void { $stat = new UserLogoutsRepository(PresetDateRanges::LAST_7_DAYS); $this->assertTrue($stat->isAllowedFilter('user')); $this->assertFalse($stat->isAllowedFilter('admin')); } - - } diff --git a/tests/Unit/Enums/PresetDateRangesTest.php b/tests/Unit/Enums/PresetDateRangesTest.php index 31d9c3e..62fb368 100644 --- a/tests/Unit/Enums/PresetDateRangesTest.php +++ b/tests/Unit/Enums/PresetDateRangesTest.php @@ -4,10 +4,12 @@ use Javaabu\Stats\Enums\PresetDateRanges; use Javaabu\Stats\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class PresetDateRangesTest extends TestCase { - public function test_it_can_generate_correct_previous_start_date(): void + #[Test] + public function it_can_generate_correct_previous_start_date(): void { $this->travelTo('2024-07-08 10:07 PM'); @@ -27,7 +29,8 @@ public function test_it_can_generate_correct_previous_start_date(): void $this->assertEquals('2015-01-01 00:00:00', PresetDateRanges::LIFETIME->getPreviousDateRange()->getDateFrom(), 'Incorrect previous start of LIFETIME'); } - public function test_it_can_generate_correct_previous_end_date(): void + #[Test] + public function it_can_generate_correct_previous_end_date(): void { $this->travelTo('2024-07-08 10:07 PM'); @@ -47,7 +50,8 @@ public function test_it_can_generate_correct_previous_end_date(): void $this->assertEquals('2019-12-31 23:59:59', PresetDateRanges::LIFETIME->getPreviousDateRange()->getDateTo(), 'Incorrect previous end of LIFETIME'); } - public function test_it_can_generate_correct_start_date(): void + #[Test] + public function it_can_generate_correct_start_date(): void { $this->travelTo('2024-07-08 10:07 PM'); @@ -67,7 +71,8 @@ public function test_it_can_generate_correct_start_date(): void $this->assertEquals('2020-01-01 00:00:00', PresetDateRanges::LIFETIME->getDateFrom(), 'Incorrect start of LIFETIME'); } - public function test_it_can_generate_correct_end_date(): void + #[Test] + public function it_can_generate_correct_end_date(): void { $this->travelTo('2024-07-08 10:07 PM'); diff --git a/tests/Unit/Enums/TimeSeriesModesTest.php b/tests/Unit/Enums/TimeSeriesModesTest.php index 2678e74..f2d87d1 100644 --- a/tests/Unit/Enums/TimeSeriesModesTest.php +++ b/tests/Unit/Enums/TimeSeriesModesTest.php @@ -4,20 +4,51 @@ use Carbon\Carbon; use Javaabu\Stats\Enums\TimeSeriesModes; +use Javaabu\Stats\Exceptions\StatsException; use Javaabu\Stats\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class TimeSeriesModesTest extends TestCase { - public function test_it_can_generate_the_correct_mysql_query_for_each_mode(): void + #[Test] + public function it_can_generate_the_correct_mysql_query_for_each_mode(): void { $this->assertEquals('DATE_FORMAT(created_at, \'%Y-%m-%d %H:00\')', TimeSeriesModes::HOUR->getSql('created_at'), 'Invalid mysql query for HOUR'); - $this->assertEquals('DATE(created_at)', TimeSeriesModes::DAY->getSql('created_at'), 'Invalid mysql query for DAY'); - $this->assertEquals('YEARWEEK(created_at, 6)', TimeSeriesModes::WEEK->getSql('created_at'), 'Invalid mysql query for WEEK'); - $this->assertEquals('DATE_FORMAT(created_at, \'%Y, %m\')', TimeSeriesModes::MONTH->getSql('created_at'), 'Invalid mysql query for MONTH'); - $this->assertEquals('YEAR(created_at)', TimeSeriesModes::YEAR->getSql('created_at'), 'Invalid mysql query for YEAR'); + $this->assertEquals('DATE_FORMAT(created_at, \'%Y-%m-%d\')', TimeSeriesModes::DAY->getSql('created_at'), 'Invalid mysql query for DAY'); + $this->assertEquals('DATE_FORMAT(DATE_SUB(created_at, INTERVAL MOD(DAYOFWEEK(created_at) - 1 - 0 + 7, 7) DAY), \'%Y-%m-%d\')', TimeSeriesModes::WEEK->getSql('created_at'), 'Invalid mysql query for WEEK'); + $this->assertEquals('DATE_FORMAT(created_at, \'%Y-%m\')', TimeSeriesModes::MONTH->getSql('created_at'), 'Invalid mysql query for MONTH'); + $this->assertEquals('DATE_FORMAT(created_at, \'%Y\')', TimeSeriesModes::YEAR->getSql('created_at'), 'Invalid mysql query for YEAR'); + $this->assertEquals(TimeSeriesModes::DAY->getSql('created_at'), TimeSeriesModes::DAY->getSql('created_at', 'mariadb')); } - public function test_it_can_get_the_date_format_for_the_time_series_mode(): void + #[Test] + public function it_can_generate_the_correct_sqlite_and_postgres_queries_for_each_mode(): void + { + $this->assertEquals('strftime(\'%Y-%m-%d %H:00\', created_at)', TimeSeriesModes::HOUR->getSql('created_at', 'sqlite')); + $this->assertEquals('strftime(\'%Y-%m\', created_at)', TimeSeriesModes::MONTH->getSql('created_at', 'sqlite')); + $this->assertEquals('to_char(created_at, \'YYYY-MM-DD\')', TimeSeriesModes::DAY->getSql('created_at', 'pgsql')); + $this->assertEquals('to_char(created_at, \'YYYY\')', TimeSeriesModes::YEAR->getSql('created_at', 'pgsql')); + } + + #[Test] + public function it_starts_weeks_on_monday_when_configured(): void + { + config()->set('stats.week_starts_on_sunday', false); + + $this->assertStringContainsString('- 1 + 7', TimeSeriesModes::WEEK->getSql('created_at')); + $this->assertEquals('2024-07-15', TimeSeriesModes::WEEK->formatDate(Carbon::parse('2024-07-16'), false)); + } + + #[Test] + public function it_refuses_unsupported_database_drivers(): void + { + $this->expectException(StatsException::class); + + TimeSeriesModes::DAY->getSql('created_at', 'sqlsrv'); + } + + #[Test] + public function it_can_get_the_date_format_for_the_time_series_mode(): void { $this->assertEquals('D MMM YY hh:mm A', TimeSeriesModes::HOUR->getDateFormat(), 'Invalid date format for HOUR'); $this->assertEquals('D MMM YY', TimeSeriesModes::DAY->getDateFormat(), 'Invalid date format for DAY'); @@ -26,7 +57,8 @@ public function test_it_can_get_the_date_format_for_the_time_series_mode(): void $this->assertEquals('YYYY', TimeSeriesModes::YEAR->getDateFormat(), 'Invalid date format for YEAR'); } - public function test_it_can_correctly_format_the_date_for_display_for_each_time_series_mode(): void + #[Test] + public function it_can_correctly_format_the_date_for_display_for_each_time_series_mode(): void { $date = Carbon::parse('2024-07-16 11:51 AM'); @@ -37,18 +69,20 @@ public function test_it_can_correctly_format_the_date_for_display_for_each_time_ $this->assertEquals('2024', TimeSeriesModes::YEAR->formatDate($date), 'Invalid formatted date for YEAR'); } - public function test_it_can_correctly_format_the_date_for_internal_use_for_each_time_series_mode(): void + #[Test] + public function it_can_correctly_format_the_date_for_internal_use_for_each_time_series_mode(): void { $date = Carbon::parse('2024-07-16 11:51 AM'); - $this->assertEquals('2024-07-16 11:51', TimeSeriesModes::HOUR->formatDate($date, false), 'Invalid internal formatted date for HOUR'); + $this->assertEquals('2024-07-16 11:00', TimeSeriesModes::HOUR->formatDate($date, false), 'Invalid internal formatted date for HOUR'); $this->assertEquals('2024-07-16', TimeSeriesModes::DAY->formatDate($date, false), 'Invalid internal formatted date for DAY'); - $this->assertEquals('202429', TimeSeriesModes::WEEK->formatDate($date, false), 'Invalid internal formatted date for WEEK'); - $this->assertEquals('2024, 07', TimeSeriesModes::MONTH->formatDate($date, false), 'Invalid internal formatted date for MONTH'); + $this->assertEquals('2024-07-14', TimeSeriesModes::WEEK->formatDate($date, false), 'Invalid internal formatted date for WEEK'); + $this->assertEquals('2024-07', TimeSeriesModes::MONTH->formatDate($date, false), 'Invalid internal formatted date for MONTH'); $this->assertEquals('2024', TimeSeriesModes::YEAR->formatDate($date, false), 'Invalid internal formatted date for YEAR'); } - public function test_it_can_generate_the_correct_diff_method_name_for_time_series_modes(): void + #[Test] + public function it_can_generate_the_correct_diff_method_name_for_time_series_modes(): void { $this->assertEquals('diffInHours', TimeSeriesModes::HOUR->diffMethodName(), 'Invalid diff method name for HOUR'); $this->assertEquals('diffInDays', TimeSeriesModes::DAY->diffMethodName(), 'Invalid diff method name for DAY'); @@ -57,7 +91,8 @@ public function test_it_can_generate_the_correct_diff_method_name_for_time_serie $this->assertEquals('diffInYears', TimeSeriesModes::YEAR->diffMethodName(), 'Invalid diff method name for YEAR'); } - public function test_it_can_generate_the_correct_increment_method_name_for_time_series_modes(): void + #[Test] + public function it_can_generate_the_correct_increment_method_name_for_time_series_modes(): void { $this->assertEquals('addHour', TimeSeriesModes::HOUR->incrementMethodName(), 'Invalid increment method name for HOUR'); $this->assertEquals('addDay', TimeSeriesModes::DAY->incrementMethodName(), 'Invalid increment method name for DAY'); @@ -66,7 +101,8 @@ public function test_it_can_generate_the_correct_increment_method_name_for_time_ $this->assertEquals('addYear', TimeSeriesModes::YEAR->incrementMethodName(), 'Invalid increment method name for YEAR'); } - public function test_it_can_generate_the_correct_interval_for_time_series_modes(): void + #[Test] + public function it_can_generate_the_correct_interval_for_time_series_modes(): void { $date_from = Carbon::parse('2024-07-09 18:34:00'); $date_to = Carbon::parse('2025-07-09 18:34:00'); @@ -78,14 +114,15 @@ public function test_it_can_generate_the_correct_interval_for_time_series_modes( $this->assertEquals(1, TimeSeriesModes::YEAR->interval($date_from, $date_to), 'Invalid interval for YEAR'); } - public function test_it_can_correctly_increment_the_date_for_each_time_series_mode(): void + #[Test] + public function it_can_correctly_increment_the_date_for_each_time_series_mode(): void { $date = Carbon::parse('2024-07-16 11:51 AM'); - $this->assertEquals('2024-07-16 12:51', TimeSeriesModes::HOUR->formatDate(TimeSeriesModes::HOUR->increment($date), false), 'Invalid incremented date for HOUR'); + $this->assertEquals('2024-07-16 12:00', TimeSeriesModes::HOUR->formatDate(TimeSeriesModes::HOUR->increment($date), false), 'Invalid incremented date for HOUR'); $this->assertEquals('2024-07-17', TimeSeriesModes::DAY->formatDate(TimeSeriesModes::DAY->increment($date), false), 'Invalid incremented date for DAY'); - $this->assertEquals('202430', TimeSeriesModes::WEEK->formatDate(TimeSeriesModes::WEEK->increment($date), false), 'Invalid incremented date for WEEK'); - $this->assertEquals('2024, 08', TimeSeriesModes::MONTH->formatDate(TimeSeriesModes::MONTH->increment($date), false), 'Invalid incremented date for MONTH'); + $this->assertEquals('2024-07-21', TimeSeriesModes::WEEK->formatDate(TimeSeriesModes::WEEK->increment($date), false), 'Invalid incremented date for WEEK'); + $this->assertEquals('2024-08', TimeSeriesModes::MONTH->formatDate(TimeSeriesModes::MONTH->increment($date), false), 'Invalid incremented date for MONTH'); $this->assertEquals('2025', TimeSeriesModes::YEAR->formatDate(TimeSeriesModes::YEAR->increment($date), false), 'Invalid incremented date for YEAR'); } } diff --git a/tests/Unit/Filters/ClosureFilterTest.php b/tests/Unit/Filters/ClosureFilterTest.php index 55cb5ca..f95248d 100644 --- a/tests/Unit/Filters/ClosureFilterTest.php +++ b/tests/Unit/Filters/ClosureFilterTest.php @@ -7,12 +7,18 @@ use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Models\User; 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 ClosureFilterTest extends TestCase { use RefreshDatabase; - public function test_it_can_filter_using_closure(): void + #[Test] + public function it_can_filter_using_closure(): void { $user_1 = User::factory()->create(['name' => 'John Doe']); $user_2 = User::factory()->create(['name' => 'Apple Gate']); @@ -21,10 +27,9 @@ public function test_it_can_filter_using_closure(): void return $query->where('name', $value); }); - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $query = User::query(); - $this->assertEquals('John Doe', $filter->apply($query, 'John Doe', $stat)->first()->name); } } diff --git a/tests/Unit/Filters/ExactFilterTest.php b/tests/Unit/Filters/ExactFilterTest.php index 8b5f132..9bedc44 100644 --- a/tests/Unit/Filters/ExactFilterTest.php +++ b/tests/Unit/Filters/ExactFilterTest.php @@ -7,39 +7,41 @@ use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Models\User; use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository; -use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsUnregistered; -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 ExactFilterTest extends TestCase { use RefreshDatabase; - public function test_it_can_filter_using_name(): void + #[Test] + public function it_can_filter_using_name(): void { $user_1 = User::factory()->create(['name' => 'User 1']); $user_2 = User::factory()->create(['name' => 'User 2']); $filter = StatsFilter::exact('name'); - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $query = User::query(); - $this->assertEquals('User 1', $filter->apply($query, 'User 1', $stat)->first()->name); } - public function test_it_can_filter_using_internal_name(): void + #[Test] + public function it_can_filter_using_internal_name(): void { $user_1 = User::factory()->create(['name' => 'User 1']); $user_2 = User::factory()->create(['name' => 'User 2']); $filter = StatsFilter::exact('username', 'name'); - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $query = User::query(); - $this->assertEquals('User 2', $filter->apply($query, 'User 2', $stat)->first()->name); } } diff --git a/tests/Unit/Filters/ScopeFilterTest.php b/tests/Unit/Filters/ScopeFilterTest.php index 3f5e798..2932d3b 100644 --- a/tests/Unit/Filters/ScopeFilterTest.php +++ b/tests/Unit/Filters/ScopeFilterTest.php @@ -7,39 +7,41 @@ use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Models\User; use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository; -use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsUnregistered; -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 ScopeFilterTest extends TestCase { use RefreshDatabase; - public function test_it_can_filter_using_name_scope(): void + #[Test] + public function it_can_filter_using_name_scope(): void { $user_1 = User::factory()->create(['name' => 'John Doe']); $user_2 = User::factory()->create(['name' => 'Apple Gate']); $filter = StatsFilter::scope('search'); - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $query = User::query(); - $this->assertEquals('John Doe', $filter->apply($query, 'Doe', $stat)->first()->name); } - public function test_it_can_filter_using_internal_scope_name(): void + #[Test] + public function it_can_filter_using_internal_scope_name(): void { $user_1 = User::factory()->create(['name' => 'John Doe']); $user_2 = User::factory()->create(['name' => 'Apple Gate']); $filter = StatsFilter::scope('name', 'search'); - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $query = User::query(); - $this->assertEquals('Apple Gate', $filter->apply($query, 'Gate', $stat)->first()->name); } } diff --git a/tests/Unit/Formatters/AbstractTimeSeriesStatsFormatterTest.php b/tests/Unit/Formatters/AbstractTimeSeriesStatsFormatterTest.php index 24e4492..e744030 100644 --- a/tests/Unit/Formatters/AbstractTimeSeriesStatsFormatterTest.php +++ b/tests/Unit/Formatters/AbstractTimeSeriesStatsFormatterTest.php @@ -5,25 +5,28 @@ use Javaabu\Stats\Formatters\TimeSeries\DefaultStatsFormatter; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\TimeSeriesStats; +use PHPUnit\Framework\Attributes\Test; class AbstractTimeSeriesStatsFormatterTest extends TestCase { - public function test_it_can_get_the_registered_format_name(): void + #[Test] + public function it_can_get_the_registered_format_name(): void { TimeSeriesStats::registerFormatters([ - 'default' => DefaultStatsFormatter::class + 'default' => DefaultStatsFormatter::class, ]); - $formatter = new DefaultStatsFormatter(); + $formatter = new DefaultStatsFormatter; $this->assertEquals('default', $formatter->getName()); } - public function test_it_returns_full_class_name_for_unregistered_formatters(): void + #[Test] + public function it_returns_full_class_name_for_unregistered_formatters(): void { TimeSeriesStats::registerFormatters([], false); - $formatter = new DefaultStatsFormatter(); + $formatter = new DefaultStatsFormatter; $this->assertEquals(DefaultStatsFormatter::class, $formatter->getName()); } diff --git a/tests/Unit/Generators/CountStatsGeneratorTest.php b/tests/Unit/Generators/CountStatsGeneratorTest.php index a0c281d..c8d4bea 100644 --- a/tests/Unit/Generators/CountStatsGeneratorTest.php +++ b/tests/Unit/Generators/CountStatsGeneratorTest.php @@ -2,14 +2,15 @@ namespace Javaabu\Stats\Tests\Unit\Generators; -use Illuminate\Foundation\Testing\RefreshDatabase; use Javaabu\Stats\Generators\CountStatGenerator; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Models\Payment; +use PHPUnit\Framework\Attributes\Test; class CountStatsGeneratorTest extends TestCase { - public function test_it_can_generate_count_stats_for_an_existing_model(): void + #[Test] + public function it_can_generate_count_stats_for_an_existing_model(): void { $generator = new CountStatGenerator('PaymentsCount', Payment::class); diff --git a/tests/Unit/Generators/SumStatsGeneratorTest.php b/tests/Unit/Generators/SumStatsGeneratorTest.php index 63a2df0..35de790 100644 --- a/tests/Unit/Generators/SumStatsGeneratorTest.php +++ b/tests/Unit/Generators/SumStatsGeneratorTest.php @@ -5,10 +5,12 @@ use Javaabu\Stats\Generators\SumStatGenerator; use Javaabu\Stats\Tests\TestCase; use Javaabu\Stats\Tests\TestSupport\Models\Payment; +use PHPUnit\Framework\Attributes\Test; class SumStatsGeneratorTest extends TestCase { - public function test_it_can_generate_sum_stats_for_an_existing_model(): void + #[Test] + public function it_can_generate_sum_stats_for_an_existing_model(): void { $generator = new SumStatGenerator('PaymentsSum', Payment::class); diff --git a/tests/Unit/Repositories/AbstractTimeSeriesStatsRepositoryTest.php b/tests/Unit/Repositories/AbstractTimeSeriesStatsRepositoryTest.php index b8e0996..47aa2ac 100644 --- a/tests/Unit/Repositories/AbstractTimeSeriesStatsRepositoryTest.php +++ b/tests/Unit/Repositories/AbstractTimeSeriesStatsRepositoryTest.php @@ -8,50 +8,60 @@ use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsUnregistered; 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 AbstractTimeSeriesStatsRepositoryTest extends TestCase { use RefreshDatabase; - public function test_it_can_get_the_registered_metric_name(): void + #[Test] + public function it_can_get_the_registered_metric_name(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $this->assertEquals('user_logouts', $stat->metric()); } - public function test_it_returns_full_class_name_for_unregistered_metrics(): void + #[Test] + public function it_returns_full_class_name_for_unregistered_metrics(): void { - $stat = new UserLogoutsUnregistered(); + $stat = new UserLogoutsUnregistered; $this->assertEquals(UserLogoutsUnregistered::class, $stat->metric()); } - public function test_it_can_generate_the_metric_name(): void + #[Test] + public function it_can_generate_the_metric_name(): void { - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $this->assertEquals('User Logouts', $stat->getName()); } - public function test_it_can_check_if_a_given_user_can_view_the_stats(): void + #[Test] + public function it_can_check_if_a_given_user_can_view_the_stats(): void { - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $this->assertTrue($stat->canView()); - $stat = new UserLogoutsWithPermission(); + $stat = new UserLogoutsWithPermission; $this->assertFalse($stat->canView()); } - public function test_it_can_generate_the_aggregate_field_label(): void + #[Test] + public function it_can_generate_the_aggregate_field_label(): void { - $stat = new UserLogoutsRepository(); + $stat = new UserLogoutsRepository; $this->assertEquals('Logouts', $stat->getAggregateFieldLabel()); } 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/Support/ExactDateRangeTest.php b/tests/Unit/Support/ExactDateRangeTest.php index e115a19..4419d4c 100644 --- a/tests/Unit/Support/ExactDateRangeTest.php +++ b/tests/Unit/Support/ExactDateRangeTest.php @@ -4,10 +4,12 @@ use Javaabu\Stats\Support\ExactDateRange; use Javaabu\Stats\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class ExactDateRangeTest extends TestCase { - public function test_it_can_generate_correct_previous_date_range(): void + #[Test] + public function it_can_generate_correct_previous_date_range(): void { $date_range = new ExactDateRange('2024-07-08 10:07 PM', '2024-07-08 11:07 PM'); $previous_date_range = $date_range->getPreviousDateRange(); diff --git a/tests/Unit/TimeSeriesStatsTest.php b/tests/Unit/TimeSeriesStatsTest.php index 170bed5..563ba08 100644 --- a/tests/Unit/TimeSeriesStatsTest.php +++ b/tests/Unit/TimeSeriesStatsTest.php @@ -11,52 +11,62 @@ use Javaabu\Stats\Tests\TestSupport\Stats\TimeSeries\UserLogoutsRepository; 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 TimeSeriesStatsTest extends TestCase { - public function test_it_can_register_a_time_series_stat_formatter(): void + #[Test] + public function it_can_register_a_time_series_stat_formatter(): void { TimeSeriesStats::registerFormatters([ - 'default' => DefaultStatsFormatter::class + 'default' => DefaultStatsFormatter::class, ]); $this->assertEquals('default', TimeSeriesStats::getNameForFormatter(DefaultStatsFormatter::class)); $this->assertEquals(DefaultStatsFormatter::class, TimeSeriesStats::getClassNameForFormat('default')); } - public function test_it_can_get_the_formatters_map(): void + #[Test] + public function it_can_get_the_formatters_map(): void { TimeSeriesStats::registerFormatters([ - 'default' => DefaultStatsFormatter::class + 'default' => DefaultStatsFormatter::class, ], false); $this->assertEquals([ - 'default' => DefaultStatsFormatter::class + 'default' => DefaultStatsFormatter::class, ], TimeSeriesStats::formattersMap()); } - public function test_it_can_get_the_class_name_for_a_formatter(): void + #[Test] + public function it_can_get_the_class_name_for_a_formatter(): void { TimeSeriesStats::registerFormatters([ - 'default' => DefaultStatsFormatter::class + 'default' => DefaultStatsFormatter::class, ]); $this->assertEquals(DefaultStatsFormatter::class, TimeSeriesStats::getClassNameForFormat('default')); } - public function test_it_can_get_the_name_for_a_formatter(): void + #[Test] + public function it_can_get_the_name_for_a_formatter(): void { TimeSeriesStats::registerFormatters([ - 'default' => DefaultStatsFormatter::class + 'default' => DefaultStatsFormatter::class, ]); $this->assertEquals('default', TimeSeriesStats::getNameForFormatter(DefaultStatsFormatter::class)); } - public function test_it_can_create_from_a_formatter(): void + #[Test] + public function it_can_create_from_a_formatter(): void { TimeSeriesStats::registerFormatters([ - 'default' => DefaultStatsFormatter::class + 'default' => DefaultStatsFormatter::class, ]); $formatter = TimeSeriesStats::createFromFormat('default'); @@ -65,49 +75,54 @@ public function test_it_can_create_from_a_formatter(): void $this->assertEquals('default', $formatter->getName()); } - public function test_it_can_register_a_time_series_stat(): void + #[Test] + public function it_can_register_a_time_series_stat(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $this->assertEquals('user_logouts', TimeSeriesStats::getMetricForStat(UserLogoutsRepository::class)); $this->assertEquals(UserLogoutsRepository::class, TimeSeriesStats::getClassNameForMetric('user_logouts')); } - public function test_it_can_get_the_stats_map(): void + #[Test] + public function it_can_get_the_stats_map(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ], false); $this->assertEquals([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ], TimeSeriesStats::statsMap()); } - public function test_it_can_get_the_class_name_for_a_metric(): void + #[Test] + public function it_can_get_the_class_name_for_a_metric(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $this->assertEquals(UserLogoutsRepository::class, TimeSeriesStats::getClassNameForMetric('user_logouts')); } - public function test_it_can_get_the_metric_name_for_a_stat(): void + #[Test] + public function it_can_get_the_metric_name_for_a_stat(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $this->assertEquals('user_logouts', TimeSeriesStats::getMetricForStat(UserLogoutsRepository::class)); } - public function test_it_can_create_from_a_metric(): void + #[Test] + public function it_can_create_from_a_metric(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $stat = TimeSeriesStats::createFromMetric('user_logouts', PresetDateRanges::LAST_7_DAYS); @@ -116,10 +131,11 @@ public function test_it_can_create_from_a_metric(): void $this->assertEquals(PresetDateRanges::LAST_7_DAYS, $stat->getDateRange()); } - public function test_it_can_get_metrics_that_allow_all_the_given_filters(): void + #[Test] + public function it_can_get_metrics_that_allow_all_the_given_filters(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $this->assertEquals(['user_logouts'], TimeSeriesStats::metricsThatAllowFilters(['user'], null, StatListReturnType::METRIC)); @@ -127,10 +143,11 @@ public function test_it_can_get_metrics_that_allow_all_the_given_filters(): void $this->assertEmpty(TimeSeriesStats::metricsThatAllowFilters(['user', 'admin'], null, StatListReturnType::METRIC)); } - public function test_it_can_get_metric_classes_that_allow_all_the_given_filters(): void + #[Test] + public function it_can_get_metric_classes_that_allow_all_the_given_filters(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $this->assertEquals(['user_logouts' => UserLogoutsRepository::class], TimeSeriesStats::metricsThatAllowFilters(['user'], null, StatListReturnType::METRIC_AND_CLASS)); @@ -138,10 +155,11 @@ public function test_it_can_get_metric_classes_that_allow_all_the_given_filters( $this->assertEmpty(TimeSeriesStats::metricsThatAllowFilters(['user', 'admin'], null, StatListReturnType::METRIC_AND_CLASS)); } - public function test_it_can_get_metric_names_that_allow_all_the_given_filters(): void + #[Test] + public function it_can_get_metric_names_that_allow_all_the_given_filters(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $this->assertEquals(['user_logouts' => 'User Logouts'], TimeSeriesStats::getMetricNames(['user'])); @@ -149,36 +167,39 @@ public function test_it_can_get_metric_names_that_allow_all_the_given_filters(): $this->assertEmpty(TimeSeriesStats::getMetricNames(['user', 'admin'])); } - public function test_it_can_get_the_metric_name_from_metric(): void + #[Test] + public function it_can_get_the_metric_name_from_metric(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ]); $this->assertEquals('User Logouts', TimeSeriesStats::getMetricName('user_logouts')); } - public function test_it_can_check_if_a_guest_can_view_any_stat(): void + #[Test] + public function it_can_check_if_a_guest_can_view_any_stat(): void { TimeSeriesStats::register([ - 'user_logouts' => UserLogoutsRepository::class + 'user_logouts' => UserLogoutsRepository::class, ], false); $this->assertTrue(TimeSeriesStats::canViewAny()); TimeSeriesStats::register([ - 'user_logouts_with_permission' => UserLogoutsWithPermission::class + 'user_logouts_with_permission' => UserLogoutsWithPermission::class, ], false); $this->assertFalse(TimeSeriesStats::canViewAny()); } - public function test_it_can_check_if_a_user_can_view_any_stat(): void + #[Test] + public function it_can_check_if_a_user_can_view_any_stat(): void { $user = User::factory()->make(); TimeSeriesStats::register([ - 'user_logouts_with_permission' => UserLogoutsWithPermission::class + 'user_logouts_with_permission' => UserLogoutsWithPermission::class, ], false); $this->assertFalse(TimeSeriesStats::canViewAny($user)); 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')); + } +} diff --git a/tests/js/StatsChart.test.tsx b/tests/js/StatsChart.test.tsx new file mode 100644 index 0000000..5ddd61c --- /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(chart.title)).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..db8e41f --- /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: 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() + + 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: timeSeries.strings.export })).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(timeSeries.strings.invalid)).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(timeSeries.strings.empty)).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.strings.title })).toBeInTheDocument() + }) +}) diff --git a/tests/js/fixtures/categorical-props.json b/tests/js/fixtures/categorical-props.json new file mode 100644 index 0000000..17b61a0 --- /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": "VkBhvZXNMoFgbLKZ85pgmsVazhhQwXga8T2v4Vau" + }, + "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..725b23f --- /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": "ދައްކާ", + "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": "ތަފާސް ހިސާބު", + "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": "ބަހާލާނީ", + "required": true, + "options": [ + { + "value": "hour", + "label": "ގަޑިއިރު" + }, + { + "value": "day", + "label": "ދުވަސް" + }, + { + "value": "week", + "label": "ހަފްތާ" + }, + { + "value": "month", + "label": "މަސް" + }, + { + "value": "year", + "label": "އަހަރު" + } + ] + } + }, + { + "type": "select", + "key": "date_range", + "name": "date_range", + "component": "Select", + "props": { + "label": "ތާރީޚުތައް", + "required": true, + "options": [ + { + "value": "today", + "label": "މިއަދު" + }, + { + "value": "yesterday", + "label": "އިއްޔެ" + }, + { + "value": "this_week", + "label": "މިހަފްތާ" + }, + { + "value": "last_week", + "label": "ފާއިތުވި ހަފްތާ" + }, + { + "value": "this_month", + "label": "މިމަސް" + }, + { + "value": "last_month", + "label": "ފާއިތުވި މަސް" + }, + { + "value": "this_year", + "label": "މިއަހަރު" + }, + { + "value": "last_year", + "label": "ފާއިތުވި އަހަރު" + }, + { + "value": "last_7_days", + "label": "ފާއިތުވި 7 ދުވަސް" + }, + { + "value": "last_14_days", + "label": "ފާއިތުވި 14 ދުވަސް" + }, + { + "value": "last_30_days", + "label": "ފާއިތުވި 30 ދުވަސް" + }, + { + "value": "last_5_years", + "label": "ފާއިތުވި 5 އަހަރު" + }, + { + "value": "last_10_years", + "label": "ފާއިތުވި 10 އަހަރު" + }, + { + "value": "lifetime", + "label": "ހުރިހާ ދުވަހެއް" + }, + { + "value": "custom", + "label": "އެހެން ތާރީޚުތަކެއް" + } + ] + } + }, + { + "type": "select", + "key": "compare_to", + "name": "compare_to", + "component": "Select", + "props": { + "label": "އަޅާކިޔާނީ", + "placeholder": "އަޅާ ނުކިޔާ", + "showPlaceholder": true, + "options": [ + { + "value": "previous", + "label": "ކުރީގެ މުއްދަތު" + }, + { + "value": "custom", + "label": "އެހެން މުއްދަތެއް" + } + ] + } + }, + { + "type": "group", + "key": "group-4", + "props": { + "columns": { + "default": 1, + "md": 2 + } + }, + "children": [ + { + "type": "date", + "key": "date_from", + "name": "date_from", + "component": "Date", + "props": { + "label": "ފެށޭ ތާރީޚު", + "required": true + } + }, + { + "type": "date", + "key": "date_to", + "name": "date_to", + "component": "Date", + "props": { + "label": "ނިމޭ ތާރީޚު", + "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": "އަޅާކިޔާ މުއްދަތު ފެށޭ ތާރީޚު", + "required": true + } + }, + { + "type": "date", + "key": "compare_date_to", + "name": "compare_date_to", + "component": "Date", + "props": { + "label": "އަޅާކިޔާ މުއްދަތު ނިމޭ ތާރީޚު", + "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": "އެކުގައި ދައްކާ", + "help": "މި ޗާޓުގައި ދައްކާނެ އެހެން ތަފާސް ހިސާބުތައް.", + "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": "ޗާޓް", + "options": [ + { + "value": "line", + "label": "ލައިން" + }, + { + "value": "area", + "label": "އޭރިއާ" + }, + { + "value": "bar", + "label": "ބާ" + } + ] + } + }, + { + "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": "އަދަދު" + }, + "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": "NPYJWzMyREosPhGV06lHFYHntOWGJefAyVJ3YrLv" + }, + "strings": { + "title": "ވަގުތާއެކު ތަފާސް ހިސާބު", + "export": "CSV ޑައުންލޯޑް ކުރައްވާ", + "empty": "ތަފާސް ހިސާބަކާއި ތާރީޚުތައް ހޮވާފައި \"ދައްކާ\" އަށް ފިއްތަވާ.", + "invalid": "ތަފާސް ހިސާބު ފެނުމަށް ފޯމުގައިވާ ކުށްތައް ރަނގަޅުކުރައްވާ." + }, + "layout": "test::layouts.admin", + "layout_section": "content", + "errors": [] +} diff --git a/tests/js/setup.ts b/tests/js/setup.ts new file mode 100644 index 0000000..b5dd8ce --- /dev/null +++ b/tests/js/setup.ts @@ -0,0 +1,34 @@ +import "@testing-library/jest-dom/vitest" +import { cleanup } from "@testing-library/react" +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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..be2eedb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node", "vite/client", "@testing-library/jest-dom"], + "paths": { + "@javaabu/stats": ["./resources/js"], + "@javaabu/stats/*": ["./resources/js/*"], + "@javaabu/stencil": ["./vendor/javaabu/stencil/resources/js"], + "@javaabu/stencil/*": ["./vendor/javaabu/stencil/resources/js/*"] + } + }, + "include": ["resources/js", "tests/js", "*.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..aece202 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite" + +/** + * Builds the prebuilt assets in dist/ for apps that don't build javaabu/stats with + * their own Vite: the Blade chart styles. javaabu/manifest finds them through + * dist/.vite/manifest.json, keyed by these entry paths. dist/ is committed; CI + * fails when it's out of date. + * + * The React charts and pages aren't prebuilt: apps build them from resources/js. + */ +export const entries = ["resources/css/charts.css"] + +export default defineConfig({ + // Relative URLs, as javaabu/manifest serves dist/ from a sub-path. + base: "./", + publicDir: false, + build: { + outDir: "dist", + emptyOutDir: true, + manifest: true, + target: "es2020", + sourcemap: false, + reportCompressedSize: false, + rollupOptions: { input: entries }, + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a7b4e05 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config" + +// @javaabu/stencil is stencil's React renderer in vendor/ (composer install first), +// as javaabu/manifest's Vite plugin resolves it in apps +export default defineConfig({ + resolve: { + alias: { + "@javaabu/stats": new URL("./resources/js", import.meta.url).pathname, + "@javaabu/stencil": new URL("./vendor/javaabu/stencil/resources/js", import.meta.url).pathname, + }, + }, + test: { + environment: "jsdom", + include: ["tests/js/**/*.test.{ts,tsx}"], + setupFiles: ["tests/js/setup.ts"], + coverage: { + provider: "v8", + include: ["resources/js/**/*.{ts,tsx}"], + reporter: ["text", "clover", "lcov"], + reportsDirectory: "coverage", + }, + }, +})