diff --git a/.app_version b/.app_version index 59dad104..40a6dfed 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.21.2 +0.23.4 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 39a87c65..a61f0adb 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,8 +1,8 @@ -# Basis-Image für Ruby und Node.js +# Base-Image for Ruby and Node.js FROM ruby:3.3.4-alpine ENV APP_PATH=/var/app -ENV BUNDLE_VERSION=2.5.9 +ENV BUNDLE_VERSION=2.5.21 ENV BUNDLE_PATH=/usr/local/bundle/gems ENV TMP_PATH=/tmp/ ENV RAILS_LOG_TO_STDOUT=true @@ -27,7 +27,7 @@ RUN apk -U add --no-cache \ && rm -rf /var/cache/apk/* \ && mkdir -p $APP_PATH -RUN gem update --system 3.5.7 && gem install bundler --version "$BUNDLE_VERSION" \ +RUN gem update --system 3.6.2 && gem install bundler --version "$BUNDLE_VERSION" \ && rm -rf $GEM_HOME/cache/* # FIXME It would be a good idea to use a other user than root, but this lead to permission error on export and maybe more yet. diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 5e2f006a..e2b7aeb3 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -8,7 +8,6 @@ services: container_name: dawarich_dev volumes: - "${PWD}:/var/app:cached" - - dawarich_gem_cache_app:/usr/local/bundle/gems_app - dawarich_public:/var/app/public - dawarich_watched:/var/app/tmp/imports/watched networks: @@ -26,19 +25,16 @@ services: DATABASE_PASSWORD: password DATABASE_NAME: dawarich_development MIN_MINUTES_SPENT_IN_CITY: 60 - APPLICATION_HOST: localhost APPLICATION_HOSTS: localhost TIME_ZONE: Europe/London APPLICATION_PROTOCOL: http DISTANCE_UNIT: km - PHOTON_API_HOST: photon.komoot.io - PHOTON_API_USE_HTTPS: true PROMETHEUS_EXPORTER_ENABLED: false PROMETHEUS_EXPORTER_HOST: 0.0.0.0 PROMETHEUS_EXPORTER_PORT: 9394 ENABLE_TELEMETRY: false # More on telemetry: https://dawarich.app/docs/tutorials/telemetry dawarich_redis: - image: redis:7.0-alpine + image: redis:7.4-alpine container_name: dawarich_redis command: redis-server networks: @@ -53,7 +49,7 @@ services: start_period: 30s timeout: 10s dawarich_db: - image: postgres:14.2-alpine + image: postgres:17-alpine container_name: dawarich_db volumes: - dawarich_db_data:/var/lib/postgresql/data @@ -72,8 +68,6 @@ services: POSTGRES_PASSWORD: password volumes: dawarich_db_data: - dawarich_gem_cache_app: - dawarich_gem_cache_sidekiq: dawarich_shared: dawarich_public: dawarich_watched: diff --git a/.env.development b/.env.development index 24313ecb..e083342f 100644 --- a/.env.development +++ b/.env.development @@ -4,5 +4,4 @@ DATABASE_PASSWORD=password DATABASE_NAME=dawarich_development DATABASE_PORT=5432 REDIS_URL=redis://localhost:6379/1 -PHOTON_API_HOST='photon.komoot.io' DISTANCE_UNIT='km' diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 2de485cd..2256378e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,11 +7,14 @@ assignees: '' --- -**Describe the bug** -A clear and concise description of what the bug is. +**OS & Hardware** +Provide your software and hardware specs **Version** -Include version of Dawarich you're experiencing problem on. +Provide the version of Dawarich you're experiencing the problem on. + +**Describe the bug** +A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 001c11e4..90c78ae1 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -15,34 +15,58 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: ref: ${{ github.event.inputs.branch || github.ref_name }} + - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v1 + uses: docker/setup-buildx-action@v3 + - name: Cache Docker layers - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx- + - name: Install dependencies run: npm install + - name: Login to Docker Hub uses: docker/login-action@v3.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set Docker tags + id: docker_meta + run: | + VERSION=${GITHUB_REF#refs/tags/} + TAGS="freikin/dawarich:${VERSION}" + + # Add :rc tag for pre-releases + if [ "${{ github.event.release.prerelease }}" = "true" ]; then + TAGS="${TAGS},freikin/dawarich:rc" + fi + + # Add :latest tag only if release is not a pre-release + if [ "${{ github.event.release.prerelease }}" != "true" ]; then + TAGS="${TAGS},freikin/dawarich:latest" + fi + + echo "tags=${TAGS}" >> $GITHUB_OUTPUT + - name: Build and push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v5 with: context: . - file: ./Dockerfile + file: ./docker/Dockerfile.dev push: true - tags: freikin/dawarich:latest,freikin/dawarich:${{ github.event.inputs.branch || github.ref_name }} + tags: ${{ steps.docker_meta.outputs.tags }} platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 4498099e..31b55115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,237 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# 0.23.4 - 2025-01-22 + +### Added + +- A test for building rc Docker image. + +### Fixed + +- Fix authentication to `GET /api/v1/countries/visited_cities` with header `Authorization: Bearer YOUR_API_KEY` instead of `api_key` query param. #679 +- Fix a bug where a gpx file with empty tracks was not being imported. #646 +- Fix a bug where rc version was being checked as a stable release. #711 + +# 0.23.3 - 2025-01-21 + +### Changed + +- Synology-related files are now up to date. #684 + +### Fixed + +- Drastically improved performance for Google's Records.json import. It will now take less than 5 minutes to import 500,000 points, which previously took a few hours. + +### Fixed + +- Add index only if it doesn't exist. + +# 0.23.1 - 2025-01-21 + +### Fixed + +- Renamed unique index on points to `unique_points_lat_long_timestamp_user_id_index` to fix naming conflict with `unique_points_index`. + +# 0.23.0 - 2025-01-20 + +## ⚠️ IMPORTANT ⚠️ + +This release includes a data migration to remove duplicated points from the database. It will not remove anything except for duplcates from the `points` table, but please make sure to create a [backup](https://dawarich.app/docs/tutorials/backup-and-restore) before updating to this version. + +### Added + +- `POST /api/v1/points/create` endpoint added. +- An index to guarantee uniqueness of points across `latitude`, `longitude`, `timestamp` and `user_id` values. This is introduced to make sure no duplicates will be created in the database in addition to previously existing validations. +- `GET /api/v1/users/me` endpoint added to get current user. + +# 0.22.4 - 2025-01-20 + +### Added + +- You can now drag-n-drop a point on the map to update its position. Enable the "Points" layer on the map to see the points. +- `PATCH /api/v1/points/:id` endpoint added to update a point. It only accepts `latitude` and `longitude` params. #51 #503 + +### Changed + +- Run seeds even in prod env so Unraid users could have default user. +- Precompile assets in production env using dummy secret key base. + +### Fixed + +- Fixed a bug where route wasn't highlighted when it was hovered or clicked. + +# 0.22.3 - 2025-01-14 + +### Changed + +- The Map now uses a canvas to draw polylines, points and fog of war. This should improve performance in browser with a lot of points and polylines. + +# 0.22.2 - 2025-01-13 + +✨ The Fancy Routes release ✨ + +### Added + +- In the Map Settings (coggle in the top left corner of the map), you can now enable/disable the Fancy Routes feature. Simply said, it will color your routes based on the speed of each segment. +- Hovering over a polyline now shows the speed of the segment. Move cursor over a polyline to see the speed of different segments. +- Distance and points number in the custom control to the map. + +### Changed + +- The name of the "Polylines" feature is now "Routes". + +⚠️ Important note on the Prometheus monitoring ⚠️ + +In the previous release, `bin/dev` command in the default `docker-compose.yml` file was replaced with `bin/rails server -p 3000 -b ::`, but this way Dawarich won't be able to start Prometheus Exporter. If you want to use Prometheus monitoring, you need to use `bin/dev` command instead. + +Example: + +```diff + dawarich_app: + image: freikin/dawarich:latest +... +- command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] ++ command: ['bin/dev'] +``` + +# 0.22.1 - 2025-01-09 + +### Removed + +- Gems caching volume from the `docker-compose.yml` file. + +To update existing `docker-compose.yml` to new changes, refer to the following: + +```diff + dawarich_app: + image: freikin/dawarich:latest +... + volumes: +- - dawarich_gem_cache_app:/usr/local/bundle/gems +... + dawarich_sidekiq: + image: freikin/dawarich:latest +... + volumes: +- - dawarich_gem_cache_app:/usr/local/bundle/gems +... + +volumes: + dawarich_db_data: +- dawarich_gem_cache_app: +- dawarich_gem_cache_sidekiq: + dawarich_shared: + dawarich_public: + dawarich_watched: +``` + +### Changed + +- `GET /api/v1/health` endpoint now returns a `X-Dawarich-Response: Hey, Im alive and authenticated!` header if user is authenticated. + +# 0.22.0 - 2025-01-09 + +⚠️ This release introduces a breaking change. ⚠️ + +Please read this release notes carefully before upgrading. + +Docker-related files were moved to the `docker` directory and some of them were renamed. Before upgrading, study carefully changes in the `docker/docker-compose.yml` file and update your docker-compose file accordingly, so it uses the new files and commands. Copying `docker/docker-compose.yml` blindly may lead to errors. + +No volumes were removed or renamed, so with a proper docker-compose file, you should be able to upgrade without any issues. + +To update existing `docker-compose.yml` to new changes, refer to the following: + +```diff + dawarich_app: + image: freikin/dawarich:latest +... +- entrypoint: dev-entrypoint.sh +- command: ['bin/dev'] ++ entrypoint: web-entrypoint.sh ++ command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] +... + dawarich_sidekiq: + image: freikin/dawarich:latest +... +- entrypoint: dev-entrypoint.sh +- command: ['bin/dev'] ++ entrypoint: sidekiq-entrypoint.sh ++ command: ['bundle', 'exec', 'sidekiq'] +``` + +Although `docker-compose.production.yml` was added, it's not being used by default. It's just an example of how to configure Dawarich for production. The default `docker-compose.yml` file is still recommended for running the app. + +### Changed + +- All docker-related files were moved to the `docker` directory. +- Default memory limit for `dawarich_app` and `dawarich_sidekiq` services was increased to 4GB. +- `dawarich_app` and `dawarich_sidekiq` services now use separate entrypoint scripts. +- Gems (dependency libraries) are now being shipped as part of the Dawarich Docker image. + +### Fixed + +- Visit suggesting job does nothing if user has no tracked points. +- `BulkStatsCalculationJob` now being called without arguments in the data migration. + +### Added + +- A proper production Dockerfile, docker-compose and env files. + +# 0.21.6 - 2025-01-07 + +### Changed + +- Disabled visit suggesting job after import. +- Improved performance of the `User#years_tracked` method. + +### Fixed + +- Inconsistent password for the `dawarich_db` service in `docker-compose_mounted_volumes.yml`. #605 +- Points are now being rendered with higher z-index than polylines. #577 +- Run cache cleaning and preheating jobs only on server start. #594 + +# 0.21.5 - 2025-01-07 + +You may now use Geoapify API for reverse geocoding. To obtain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/). + +### Added + +- Geoapify API support for reverse geocoding. Provide `GEOAPIFY_API_KEY` env var to use it. + +### Removed + +- Photon ENV vars from the `.env.development` and docker-compose.yml files. +- `APPLICATION_HOST` env var. +- `REVERSE_GEOCODING_ENABLED` env var. + +# 0.21.4 - 2025-01-05 + +### Fixed + +- Fixed a bug where Photon API for patreon supporters was not being used for reverse geocoding. + +# 0.21.3 - 2025-01-04 + +### Added + +- A notification about Photon API being under heavy load. + +### Removed + +- The notification about telemetry being enabled. + +### Reverted + +- ~~Imported points will now be reverse geocoded only after import is finished.~~ + # 0.21.2 - 2024-12-25 +### Added + +- Logging for Immich responses. +- Watcher now supports all data formats that can be imported via web interface. + ### Changed - Imported points will now be reverse geocoded only after import is finished. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d809b2d7..00000000 --- a/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -FROM ruby:3.3.4-alpine - -ENV APP_PATH=/var/app -ENV BUNDLE_VERSION=2.5.9 -ENV BUNDLE_PATH=/usr/local/bundle/gems -ENV TMP_PATH=/tmp/ -ENV RAILS_LOG_TO_STDOUT=true -ENV RAILS_PORT=3000 - -# Install dependencies for application -RUN apk -U add --no-cache \ - build-base \ - git \ - postgresql-dev \ - postgresql-client \ - libxml2-dev \ - libxslt-dev \ - nodejs \ - yarn \ - imagemagick \ - tzdata \ - less \ - yaml-dev \ - # gcompat for nokogiri on mac m1 - gcompat \ - && rm -rf /var/cache/apk/* \ - && mkdir -p $APP_PATH - -RUN gem install bundler --version "$BUNDLE_VERSION" \ - && rm -rf $GEM_HOME/cache/* - -# Navigate to app directory -WORKDIR $APP_PATH - -COPY Gemfile Gemfile.lock vendor .ruby-version ./ - -# Install missing gems -RUN bundle config set --local path 'vendor/bundle' \ - && bundle install --jobs 20 --retry 5 - -COPY . ./ - -# Copy entrypoint scripts and grant execution permissions -COPY ./dev-docker-entrypoint.sh /usr/local/bin/dev-entrypoint.sh -RUN chmod +x /usr/local/bin/dev-entrypoint.sh - -EXPOSE $RAILS_PORT - -ENTRYPOINT [ "bundle", "exec" ] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 8d668cf3..00000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,41 +0,0 @@ -FROM ruby:3.3.4-alpine - -ENV APP_PATH=/var/app -ENV BUNDLE_VERSION=2.5.9 -ENV BUNDLE_PATH=/usr/local/bundle/gems -ENV TMP_PATH=/tmp/ -ENV RAILS_LOG_TO_STDOUT=true -ENV RAILS_PORT=3000 - -# install dependencies for application -RUN apk -U add --no-cache \ - build-base \ - git \ - postgresql-dev \ - postgresql-client \ - libxml2-dev \ - libxslt-dev \ - nodejs \ - yarn \ - imagemagick \ - tzdata \ - less \ - # gcompat for nokogiri on mac m1 - gcompat \ - && rm -rf /var/cache/apk/* \ - && mkdir -p $APP_PATH - -RUN gem install bundler --version "$BUNDLE_VERSION" \ - && rm -rf $GEM_HOME/cache/* - -# copy entrypoint scripts and grant execution permissions -COPY ./dev-docker-entrypoint.sh /usr/local/bin/dev-entrypoint.sh -COPY ./test-docker-entrypoint.sh /usr/local/bin/test-entrypoint.sh -RUN chmod +x /usr/local/bin/dev-entrypoint.sh && chmod +x /usr/local/bin/test-entrypoint.sh - -# navigate to app directory -WORKDIR $APP_PATH - -EXPOSE $RAILS_PORT - -ENTRYPOINT [ "bundle", "exec" ] diff --git a/Gemfile.lock b/Gemfile.lock index 73829ee8..43f74521 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,15 +88,15 @@ GEM base64 (0.2.0) bcrypt (3.1.20) benchmark (0.4.0) - bigdecimal (3.1.8) + bigdecimal (3.1.9) bootsnap (1.18.4) msgpack (~> 1.2) builder (3.3.0) byebug (11.1.3) chartkick (5.1.2) coderay (1.1.3) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) content_disposition (1.0.0) crack (1.0.0) bigdecimal @@ -156,7 +156,7 @@ GEM csv mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) - i18n (1.14.6) + i18n (1.14.7) concurrent-ruby (~> 1.0) importmap-rails (2.1.0) actionpack (>= 6.0.0) @@ -166,7 +166,7 @@ GEM irb (1.14.3) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.7.4) + json (2.9.1) json-schema (5.0.1) addressable (~> 2.8) kaminari (1.2.2) @@ -182,13 +182,13 @@ GEM kaminari-core (= 1.2.2) kaminari-core (1.2.2) language_server-protocol (3.17.0.3) - logger (1.6.4) + logger (1.6.5) lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.23.1) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -199,6 +199,7 @@ GEM marcel (1.0.4) method_source (1.1.0) mini_mime (1.1.5) + mini_portile2 (2.8.8) minitest (5.25.4) msgpack (1.7.3) multi_xml (0.7.1) @@ -213,26 +214,27 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.17.2-aarch64-linux) + nokogiri (1.18.2) + mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.17.2-arm-linux) + nokogiri (1.18.2-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.17.2-arm64-darwin) + nokogiri (1.18.2-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.17.2-x86-linux) + nokogiri (1.18.2-arm64-darwin) racc (~> 1.4) - nokogiri (1.17.2-x86_64-darwin) + nokogiri (1.18.2-x86_64-darwin) racc (~> 1.4) - nokogiri (1.17.2-x86_64-linux) + nokogiri (1.18.2-x86_64-linux-gnu) racc (~> 1.4) - oj (3.16.8) + oj (3.16.9) bigdecimal (>= 3.0) ostruct (>= 0.2) optimist (3.2.0) orm_adapter (0.5.0) ostruct (0.6.1) parallel (1.26.3) - parser (3.3.5.0) + parser (3.3.7.0) ast (~> 2.4.1) racc patience_diff (1.2.0) @@ -248,7 +250,7 @@ GEM pry (>= 0.13, < 0.15) pry-rails (0.3.11) pry (>= 0.13.0) - psych (5.2.2) + psych (5.2.3) date stringio public_suffix (6.0.1) @@ -259,9 +261,10 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.1.8) - rack-session (2.0.0) + rack-session (2.1.0) + base64 (>= 0.1.0) rack (>= 3.0.0) - rack-test (2.1.0) + rack-test (2.2.0) rack (>= 1.3) rackup (2.2.1) rack (>= 3) @@ -296,13 +299,13 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) - rdoc (6.10.0) + rdoc (6.11.0) psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) - redis-client (0.23.0) + redis-client (0.23.2) connection_pool - regexp_parser (2.9.2) + regexp_parser (2.10.0) reline (0.6.0) io-console (~> 0.5) request_store (1.7.0) @@ -339,19 +342,19 @@ GEM rswag-ui (2.16.0) actionpack (>= 5.2, < 8.1) railties (>= 5.2, < 8.1) - rubocop (1.67.0) + rubocop (1.70.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.3) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.37.0) parser (>= 3.3.1.0) - rubocop-rails (2.27.0) + rubocop-rails (2.29.0) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.52.0, < 2.0) @@ -368,7 +371,7 @@ GEM logger rack (>= 2.2.4) redis-client (>= 0.22.2) - sidekiq-cron (2.0.1) + sidekiq-cron (2.1.0) cronex (>= 0.13.0) fugit (~> 1.8, >= 1.11.1) globalid (>= 1.0.1) @@ -393,19 +396,19 @@ GEM stringio (3.1.2) strong_migrations (2.1.0) activerecord (>= 6.1) - super_diff (0.14.0) + super_diff (0.15.0) attr_extras (>= 6.2.4) diff-lcs patience_diff - tailwindcss-rails (3.0.0) + tailwindcss-rails (3.3.0) railties (>= 7.0.0) tailwindcss-ruby - tailwindcss-ruby (3.4.14) - tailwindcss-ruby (3.4.14-aarch64-linux) - tailwindcss-ruby (3.4.14-arm-linux) - tailwindcss-ruby (3.4.14-arm64-darwin) - tailwindcss-ruby (3.4.14-x86_64-darwin) - tailwindcss-ruby (3.4.14-x86_64-linux) + tailwindcss-ruby (3.4.17) + tailwindcss-ruby (3.4.17-aarch64-linux) + tailwindcss-ruby (3.4.17-arm-linux) + tailwindcss-ruby (3.4.17-arm64-darwin) + tailwindcss-ruby (3.4.17-x86_64-darwin) + tailwindcss-ruby (3.4.17-x86_64-linux) thor (1.3.2) timeout (0.4.2) turbo-rails (2.0.11) @@ -414,7 +417,9 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode (0.4.4.5) - unicode-display_width (2.6.0) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) uri (1.0.2) useragent (0.16.11) warden (1.2.9) diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index e27405e3..a062f2a9 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -1,6 +1,6 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.mockup-code{border-radius:var(--rounded-box,1rem);min-width:18rem;overflow:hidden;overflow-x:auto;position:relative;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-bottom:1.25rem;padding-top:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;opacity:.5;text-align:right;width:2rem}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.radio,.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer}.select{border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{border-color:transparent;border-width:var(--tab-border,0);display:none;grid-column-end:span 9999;grid-column-start:1;grid-row-start:2;margin-top:calc(var(--tab-border)*-1)}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var( +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.mockup-code{border-radius:var(--rounded-box,1rem);min-width:18rem;overflow:hidden;overflow-x:auto;position:relative;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-bottom:1.25rem;padding-top:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;opacity:.5;text-align:right;width:2rem}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{border-color:transparent;border-width:var(--tab-border,0);display:none;grid-column-end:span 9999;grid-column-start:1;grid-row-start:2;margin-top:calc(var(--tab-border)*-1)}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var( --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact -.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-1\/6,.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:w-5\/6{width:83.333333%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}} \ No newline at end of file + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact +.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}} \ No newline at end of file diff --git a/app/assets/stylesheets/fog-of-war.css b/app/assets/stylesheets/fog-of-war.css deleted file mode 100644 index f671b86d..00000000 --- a/app/assets/stylesheets/fog-of-war.css +++ /dev/null @@ -1,21 +0,0 @@ -/* Ensure fog overlay is positioned relative to the map container */ -#fog { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); /* Adjust the opacity here */ - pointer-events: none; - mix-blend-mode: multiply; - z-index: 1000; -} - -.unfogged-circle { - position: absolute; - pointer-events: none; - border-radius: 50%; - background: white; - mix-blend-mode: destination-out; - filter: blur(3px); /* Apply no blur to the circles */ -} diff --git a/app/controllers/api/v1/health_controller.rb b/app/controllers/api/v1/health_controller.rb index 53563cb0..87df7d96 100644 --- a/app/controllers/api/v1/health_controller.rb +++ b/app/controllers/api/v1/health_controller.rb @@ -4,8 +4,12 @@ class Api::V1::HealthController < ApiController skip_before_action :authenticate_api_key def index - response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!') + if current_api_user + response.set_header('X-Dawarich-Response', 'Hey, I\'m alive and authenticated!') + else + response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!') + end + render json: { status: 'ok' } end end - diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index a70dabdc..f09340b8 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -21,6 +21,20 @@ class Api::V1::PointsController < ApiController render json: serialized_points end + def create + Points::CreateJob.perform_later(batch_params, current_api_user.id) + + render json: { message: 'Points are being processed' } + end + + def update + point = current_api_user.tracked_points.find(params[:id]) + + point.update(point_params) + + render json: point_serializer.new(point).call + end + def destroy point = current_api_user.tracked_points.find(params[:id]) point.destroy @@ -30,6 +44,14 @@ class Api::V1::PointsController < ApiController private + def point_params + params.require(:point).permit(:latitude, :longitude) + end + + def batch_params + params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {}) + end + def point_serializer params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer end diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index f87d9df7..316c201e 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -27,7 +27,8 @@ class Api::V1::SettingsController < ApiController :meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :preferred_map_layer, :points_rendering_mode, :live_map_enabled, - :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key + :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, + :speed_colored_routes ) end end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb new file mode 100644 index 00000000..4fbb3f60 --- /dev/null +++ b/app/controllers/api/v1/users_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Api::V1::UsersController < ApiController + def me + render json: { user: current_api_user } + end +end diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index ac960928..bad160d5 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -6,7 +6,6 @@ class MapController < ApplicationController def index @points = points.where('timestamp >= ? AND timestamp <= ?', start_at, end_at) - @countries_and_cities = CountriesAndCities.new(@points).call @coordinates = @points.pluck(:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id, :country) .map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] } @@ -14,6 +13,7 @@ class MapController < ApplicationController @start_at = Time.zone.at(start_at) @end_at = Time.zone.at(end_at) @years = (@start_at.year..@end_at.year).to_a + @points_number = @coordinates.count end private @@ -36,7 +36,7 @@ class MapController < ApplicationController @distance ||= 0 @coordinates.each_cons(2) do - @distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]]) + @distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT) end @distance.round(1) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 40893763..313b477d 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -5,27 +5,34 @@ import consumer from "../channels/consumer"; import { createMarkersArray } from "../maps/markers"; -import { createPolylinesLayer } from "../maps/polylines"; -import { updatePolylinesOpacity } from "../maps/polylines"; +import { + createPolylinesLayer, + updatePolylinesOpacity, + updatePolylinesColors, + calculateSpeed, + getSpeedColor +} from "../maps/polylines"; import { fetchAndDrawAreas } from "../maps/areas"; import { handleAreaCreated } from "../maps/areas"; -import { showFlashMessage } from "../maps/helpers"; -import { fetchAndDisplayPhotos } from '../maps/helpers'; +import { showFlashMessage, fetchAndDisplayPhotos, debounce } from "../maps/helpers"; -import { osmMapLayer } from "../maps/layers"; -import { osmHotMapLayer } from "../maps/layers"; -import { OPNVMapLayer } from "../maps/layers"; -import { openTopoMapLayer } from "../maps/layers"; -import { cyclOsmMapLayer } from "../maps/layers"; -import { esriWorldStreetMapLayer } from "../maps/layers"; -import { esriWorldTopoMapLayer } from "../maps/layers"; -import { esriWorldImageryMapLayer } from "../maps/layers"; -import { esriWorldGrayCanvasMapLayer } from "../maps/layers"; +import { + osmMapLayer, + osmHotMapLayer, + OPNVMapLayer, + openTopoMapLayer, + cyclOsmMapLayer, + esriWorldStreetMapLayer, + esriWorldTopoMapLayer, + esriWorldImageryMapLayer, + esriWorldGrayCanvasMapLayer +} from "../maps/layers"; import { countryCodesMap } from "../maps/country_codes"; import "leaflet-draw"; +import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; export default class extends Controller { static targets = ["container"]; @@ -48,11 +55,41 @@ export default class extends Controller { this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw"; this.liveMapEnabled = this.userSettings.live_map_enabled || false; this.countryCodesMap = countryCodesMap(); + this.speedColoredPolylines = this.userSettings.speed_colored_routes || false; this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111]; this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14); + // Add scale control + L.control.scale({ + position: 'bottomright', + imperial: this.distanceUnit === 'mi', + metric: this.distanceUnit === 'km', + maxWidth: 120 + }).addTo(this.map) + + // Add stats control + const StatsControl = L.Control.extend({ + options: { + position: 'bottomright' + }, + onAdd: (map) => { + const div = L.DomUtil.create('div', 'leaflet-control-stats'); + const distance = this.element.dataset.distance || '0'; + const pointsNumber = this.element.dataset.points_number || '0'; + const unit = this.distanceUnit === 'mi' ? 'mi' : 'km'; + div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`; + div.style.backgroundColor = 'white'; + div.style.padding = '0 5px'; + div.style.marginRight = '5px'; + div.style.display = 'inline-block'; + return div; + } + }); + + new StatsControl().addTo(this.map); + // Set the maximum bounds to prevent infinite scroll var southWest = L.latLng(-120, -210); var northEast = L.latLng(120, 210); @@ -60,13 +97,16 @@ export default class extends Controller { this.map.setMaxBounds(bounds); - this.markersArray = createMarkersArray(this.markers, this.userSettings); + this.markersArray = createMarkersArray(this.markers, this.userSettings, this.apiKey); this.markersLayer = L.layerGroup(this.markersArray); this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]); this.polylinesLayer = createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit); this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map); - this.fogOverlay = L.layerGroup(); // Initialize fog layer + + // Create a proper Leaflet layer for fog + this.fogOverlay = createFogOverlay(); + this.areasLayer = L.layerGroup(); // Initialize areas layer this.photoMarkers = L.layerGroup(); @@ -76,26 +116,53 @@ export default class extends Controller { this.addSettingsButton(); } + // Initialize layers for the layer control const controlsLayer = { Points: this.markersLayer, - Polylines: this.polylinesLayer, + Routes: this.polylinesLayer, Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, + "Fog of War": new this.fogOverlay(), "Scratch map": this.scratchLayer, Areas: this.areasLayer, Photos: this.photoMarkers }; - // Add scale control to bottom right - L.control.scale({ - position: 'bottomright', - imperial: this.distanceUnit === 'mi', - metric: this.distanceUnit === 'km', - maxWidth: 120 - }).addTo(this.map) - + // Initialize layer control first this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); + // Add the toggle panel button + this.addTogglePanelButton(); + + // Check if we should open the panel based on localStorage or URL params + const urlParams = new URLSearchParams(window.location.search); + const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; + const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); + + // Always create the panel first + this.toggleRightPanel(); + + // Then hide it if it shouldn't be open + if (!isPanelOpen && !hasDateParams) { + const panel = document.querySelector('.leaflet-right-panel'); + if (panel) { + panel.style.display = 'none'; + localStorage.setItem('mapPanelOpen', 'false'); + } + } + + // Update event handlers + this.map.on('moveend', () => { + if (document.getElementById('fog')) { + this.updateFog(this.markers, this.clearFogRadius); + } + }); + + this.map.on('zoomend', () => { + if (document.getElementById('fog')) { + this.updateFog(this.markers, this.clearFogRadius); + } + }); + // Fetch and draw areas when the map is loaded fetchAndDrawAreas(this.areasLayer, this.apiKey); @@ -173,26 +240,6 @@ export default class extends Controller { if (this.liveMapEnabled) { this.setupSubscription(); } - - // Add the toggle panel button - this.addTogglePanelButton(); - - // Check if we should open the panel based on localStorage or URL params - const urlParams = new URLSearchParams(window.location.search); - const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; - const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); - - // Always create the panel first - this.toggleRightPanel(); - - // Then hide it if it shouldn't be open - if (!isPanelOpen && !hasDateParams) { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - panel.style.display = 'none'; - localStorage.setItem('mapPanelOpen', 'false'); - } - } } disconnect() { @@ -439,7 +486,7 @@ export default class extends Controller { this.map.removeControl(this.layerControl); const controlsLayer = { Points: this.markersLayer, - Polylines: this.polylinesLayer, + Routes: this.polylinesLayer, Heatmap: this.heatmapLayer, "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayer, @@ -491,39 +538,11 @@ export default class extends Controller { } updateFog(markers, clearFogRadius) { - var fog = document.getElementById('fog'); - fog.innerHTML = ''; // Clear previous circles - markers.forEach((point) => { - const radiusInPixels = this.metersToPixels(this.map, clearFogRadius); - this.clearFog(point[0], point[1], radiusInPixels); - }); - } - - metersToPixels(map, meters) { - const zoom = map.getZoom(); - const latLng = map.getCenter(); // Get map center for correct projection - const metersPerPixel = this.getMetersPerPixel(latLng.lat, zoom); - return meters / metersPerPixel; - } - - getMetersPerPixel(latitude, zoom) { - const earthCircumference = 40075016.686; // Earth's circumference in meters - const metersPerPixel = earthCircumference * Math.cos(latitude * Math.PI / 180) / Math.pow(2, zoom + 8); - return metersPerPixel; - } - - clearFog(lat, lng, radius) { - var fog = document.getElementById('fog'); - var point = this.map.latLngToContainerPoint([lat, lng]); - var size = radius * 2; - var circle = document.createElement('div'); - circle.className = 'unfogged-circle'; - circle.style.width = size + 'px'; - circle.style.height = size + 'px'; - circle.style.left = (point.x - radius) + 'px'; - circle.style.top = (point.y - radius) + 'px'; - circle.style.backdropFilter = 'blur(0px)'; // Remove blur for the circles - fog.appendChild(circle); + const fog = document.getElementById('fog'); + if (!fog) { + initializeFogCanvas(this.map); + } + requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius)); } initializeDrawControl() { @@ -677,6 +696,12 @@ export default class extends Controller { + + `; @@ -717,8 +742,13 @@ export default class extends Controller { } } + speedColoredRoutesChecked() { + return this.userSettings.speed_colored_routes ? 'checked' : ''; + } + updateSettings(event) { event.preventDefault(); + console.log('Form submitted'); fetch(`/api/v1/settings?api_key=${this.apiKey}`, { method: 'PATCH', @@ -732,12 +762,14 @@ export default class extends Controller { time_threshold_minutes: event.target.time_threshold_minutes.value, merge_threshold_minutes: event.target.merge_threshold_minutes.value, points_rendering_mode: event.target.points_rendering_mode.value, - live_map_enabled: event.target.live_map_enabled.checked + live_map_enabled: event.target.live_map_enabled.checked, + speed_colored_routes: event.target.speed_colored_routes.checked }, }), }) .then((response) => response.json()) .then((data) => { + console.log('Settings update response:', data); if (data.status === 'success') { showFlashMessage('notice', data.message); this.updateMapWithNewSettings(data.settings); @@ -748,144 +780,92 @@ export default class extends Controller { } else { showFlashMessage('error', data.message); } + }) + .catch(error => { + console.error('Settings update error:', error); + showFlashMessage('error', 'Failed to update settings'); }); } updateMapWithNewSettings(newSettings) { - const currentLayerStates = this.getLayerControlStates(); + // Show loading indicator + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'map-loading-overlay'; + loadingDiv.innerHTML = '
Updating map...
'; + document.body.appendChild(loadingDiv); - // Update local state with new settings - this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; - this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; - - // Preserve existing layer instances if they exist - const preserveLayers = { - Points: this.markersLayer, - Polylines: this.polylinesLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - Areas: this.areasLayer, - }; - - // Clear all layers except base layers - this.map.eachLayer((layer) => { - if (!(layer instanceof L.TileLayer)) { - this.map.removeLayer(layer); + try { + // Update settings first + if (newSettings.speed_colored_routes !== this.userSettings.speed_colored_routes) { + if (this.polylinesLayer) { + updatePolylinesColors( + this.polylinesLayer, + newSettings.speed_colored_routes + ); + } } - }); - // Recreate layers only if they don't exist - this.markersLayer = preserveLayers.Points || L.layerGroup(createMarkersArray(this.markers, newSettings)); - this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit); - this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 }); - this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup(); - this.areasLayer = preserveLayers.Areas || L.layerGroup(); - - // Redraw areas - fetchAndDrawAreas(this.areasLayer, this.apiKey); - - let fogEnabled = false; - document.getElementById('fog').style.display = 'none'; - - this.map.on('overlayadd', (e) => { - if (e.name === 'Fog of War') { - fogEnabled = true; - document.getElementById('fog').style.display = 'block'; - this.updateFog(this.markers, this.clearFogRadius); + if (newSettings.route_opacity !== this.userSettings.route_opacity) { + const newOpacity = parseFloat(newSettings.route_opacity) || 0.6; + if (this.polylinesLayer) { + updatePolylinesOpacity(this.polylinesLayer, newOpacity); + } } - }); - this.map.on('overlayremove', (e) => { - if (e.name === 'Fog of War') { - fogEnabled = false; - document.getElementById('fog').style.display = 'none'; + // Update the local settings + this.userSettings = { ...this.userSettings, ...newSettings }; + this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6; + this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50; + + // Store current layer states + const layerStates = { + Points: this.map.hasLayer(this.markersLayer), + Routes: this.map.hasLayer(this.polylinesLayer), + Heatmap: this.map.hasLayer(this.heatmapLayer), + "Fog of War": this.map.hasLayer(this.fogOverlay), + "Scratch map": this.map.hasLayer(this.scratchLayer), + Areas: this.map.hasLayer(this.areasLayer), + Photos: this.map.hasLayer(this.photoMarkers) + }; + + // Remove only the layer control + if (this.layerControl) { + this.map.removeControl(this.layerControl); } - }); - this.map.on('zoomend moveend', () => { - if (fogEnabled) { - this.updateFog(this.markers, this.clearFogRadius); - } - }); + // Create new controls layer object + const controlsLayer = { + Points: this.markersLayer || L.layerGroup(), + Routes: this.polylinesLayer || L.layerGroup(), + Heatmap: this.heatmapLayer || L.heatLayer([]), + "Fog of War": new this.fogOverlay(), + "Scratch map": this.scratchLayer || L.layerGroup(), + Areas: this.areasLayer || L.layerGroup(), + Photos: this.photoMarkers || L.layerGroup() + }; - this.addLastMarker(this.map, this.markers); - this.addEventListeners(); - this.initializeDrawControl(); - updatePolylinesOpacity(this.polylinesLayer, this.routeOpacity); + // Re-add the layer control in the same position + this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - this.map.on('overlayadd', (e) => { - if (e.name === 'Areas') { - this.map.addControl(this.drawControl); - } - }); + // Restore layer visibility states + Object.entries(layerStates).forEach(([name, wasVisible]) => { + const layer = controlsLayer[name]; + if (wasVisible && layer) { + layer.addTo(this.map); + } else if (layer && this.map.hasLayer(layer)) { + this.map.removeLayer(layer); + } + }); - this.map.on('overlayremove', (e) => { - if (e.name === 'Areas') { - this.map.removeControl(this.drawControl); - } - }); - - this.applyLayerControlStates(currentLayerStates); - } - - getLayerControlStates() { - const controls = {}; - - this.map.eachLayer((layer) => { - const layerName = this.getLayerName(layer); - - if (layerName) { - controls[layerName] = this.map.hasLayer(layer); - } - }); - - return controls; - } - - getLayerName(layer) { - const controlLayers = { - Points: this.markersLayer, - Polylines: this.polylinesLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - Areas: this.areasLayer, - }; - - for (const [name, val] of Object.entries(controlLayers)) { - if (val && val.hasLayer && layer && val.hasLayer(layer)) // Check if the group layer contains the current layer - return name; + } catch (error) { + console.error('Error updating map settings:', error); + console.error(error.stack); + } finally { + // Remove loading indicator + setTimeout(() => { + document.body.removeChild(loadingDiv); + }, 500); } - - // Direct instance matching - for (const [name, val] of Object.entries(controlLayers)) { - if (val === layer) return name; - } - - return undefined; // Indicate no matching layer name found - } - - applyLayerControlStates(states) { - const layerControl = { - Points: this.markersLayer, - Polylines: this.polylinesLayer, - Heatmap: this.heatmapLayer, - "Fog of War": this.fogOverlay, - Areas: this.areasLayer, - }; - - for (const [name, isVisible] of Object.entries(states)) { - const layer = layerControl[name]; - - if (isVisible && !this.map.hasLayer(layer)) { - this.map.addLayer(layer); - } else if (this.map.hasLayer(layer)) { - this.map.removeLayer(layer); - } - } - - // Ensure the layer control reflects the current state - this.map.removeControl(this.layerControl); - this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map); } createPhotoMarker(photo) { diff --git a/app/javascript/controllers/trip_map_controller.js b/app/javascript/controllers/trip_map_controller.js index b7df8943..b2a18bfb 100644 --- a/app/javascript/controllers/trip_map_controller.js +++ b/app/javascript/controllers/trip_map_controller.js @@ -43,8 +43,9 @@ export default class extends Controller { const polyline = L.polyline(points, { color: 'blue', + opacity: 0.8, weight: 3, - opacity: 0.8 + zIndexOffset: 400 }).addTo(this.map) this.map.fitBounds(polyline.getBounds(), { diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 497fe5e3..602c04be 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -138,7 +138,14 @@ export default class extends Controller { addMarkers() { this.coordinates.forEach(coord => { - const marker = L.circleMarker([coord[0], coord[1]], {radius: 4}) + const marker = L.circleMarker( + [coord[0], coord[1]], + { + radius: 4, + color: coord[5] < 0 ? "orange" : "blue", + zIndexOffset: 1000 + } + ) const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit) marker.bindPopup(popupContent) @@ -152,8 +159,9 @@ export default class extends Controller { const points = this.coordinates.map(coord => [coord[0], coord[1]]) const polyline = L.polyline(points, { color: 'blue', + opacity: 0.8, weight: 3, - opacity: 0.8 + zIndexOffset: 400 }) // Add to polylines layer instead of directly to map this.polylinesLayer.addTo(this.map) diff --git a/app/javascript/controllers/visit_modal_map_controller.js b/app/javascript/controllers/visit_modal_map_controller.js index f8f4c9ea..5fcb0547 100644 --- a/app/javascript/controllers/visit_modal_map_controller.js +++ b/app/javascript/controllers/visit_modal_map_controller.js @@ -27,7 +27,15 @@ export default class extends Controller { addMarkers() { this.coordinates.forEach((coordinate) => { - L.circleMarker([coordinate[0], coordinate[1]], { radius: 4 }).addTo(this.map); + L.circleMarker( + [coordinate[0], coordinate[1]], + { + radius: 4, + color: coordinate[5] < 0 ? "orange" : "blue", + zIndexOffset: 1000 + } + ).addTo(this.map); }); } } + diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js new file mode 100644 index 00000000..8e910274 --- /dev/null +++ b/app/javascript/maps/fog_of_war.js @@ -0,0 +1,108 @@ +export function initializeFogCanvas(map) { + // Remove existing fog canvas if it exists + const oldFog = document.getElementById('fog'); + if (oldFog) oldFog.remove(); + + // Create new fog canvas + const fog = document.createElement('canvas'); + fog.id = 'fog'; + fog.style.position = 'absolute'; + fog.style.top = '0'; + fog.style.left = '0'; + fog.style.pointerEvents = 'none'; + fog.style.zIndex = '400'; + + // Set canvas size to match map container + const mapSize = map.getSize(); + fog.width = mapSize.x; + fog.height = mapSize.y; + + // Add canvas to map container + map.getContainer().appendChild(fog); + + return fog; +} + +export function drawFogCanvas(map, markers, clearFogRadius) { + const fog = document.getElementById('fog'); + // Return early if fog element doesn't exist or isn't a canvas + if (!fog || !(fog instanceof HTMLCanvasElement)) return; + + const ctx = fog.getContext('2d'); + if (!ctx) return; + + const size = map.getSize(); + + // Clear the canvas + ctx.clearRect(0, 0, size.x, size.y); + + // Keep the light fog for unexplored areas + ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + ctx.fillRect(0, 0, size.x, size.y); + + // Set up for "cutting" holes + ctx.globalCompositeOperation = 'destination-out'; + + // Draw clear circles for each point + markers.forEach(point => { + const latLng = L.latLng(point[0], point[1]); + const pixelPoint = map.latLngToContainerPoint(latLng); + const radiusInPixels = metersToPixels(map, clearFogRadius); + + // Make explored areas completely transparent + const gradient = ctx.createRadialGradient( + pixelPoint.x, pixelPoint.y, 0, + pixelPoint.x, pixelPoint.y, radiusInPixels + ); + gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); // 100% transparent + gradient.addColorStop(0.85, 'rgba(255, 255, 255, 1)'); // Still 100% transparent + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); // Fade to fog at edge + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(pixelPoint.x, pixelPoint.y, radiusInPixels, 0, Math.PI * 2); + ctx.fill(); + }); + + // Reset composite operation + ctx.globalCompositeOperation = 'source-over'; +} + +function metersToPixels(map, meters) { + const zoom = map.getZoom(); + const latLng = map.getCenter(); + const metersPerPixel = getMetersPerPixel(latLng.lat, zoom); + return meters / metersPerPixel; +} + +function getMetersPerPixel(latitude, zoom) { + const earthCircumference = 40075016.686; + return earthCircumference * Math.cos(latitude * Math.PI / 180) / Math.pow(2, zoom + 8); +} + +export function createFogOverlay() { + return L.Layer.extend({ + onAdd: (map) => { + initializeFogCanvas(map); + + // Add drag event handlers to update fog during marker movement + map.on('drag', () => { + const fog = document.getElementById('fog'); + if (fog) { + // Update fog canvas position to match map position + const mapPos = map.getContainer().getBoundingClientRect(); + fog.style.left = `${mapPos.left}px`; + fog.style.top = `${mapPos.top}px`; + } + }); + }, + onRemove: (map) => { + const fog = document.getElementById('fog'); + if (fog) { + fog.remove(); + } + // Clean up event listener + map.off('drag'); + } + }); +} diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index 4dca082d..7c850f03 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -297,3 +297,15 @@ export function createPhotoMarker(photo, userSettings, photoMarkers, apiKey) { photoMarkers.addLayer(marker); } + +export function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js index 6b506285..610a81dc 100644 --- a/app/javascript/maps/markers.js +++ b/app/javascript/maps/markers.js @@ -1,24 +1,164 @@ import { createPopupContent } from "./popups"; -export function createMarkersArray(markersData, userSettings) { - if (userSettings.pointsRenderingMode === "simplified") { - return createSimplifiedMarkers(markersData); - } else { - return markersData.map((marker) => { - const [lat, lon] = marker; +export function createMarkersArray(markersData, userSettings, apiKey) { + // Create a canvas renderer + const renderer = L.canvas({ padding: 0.5 }); - const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit); - let markerColor = marker[5] < 0 ? "orange" : "blue"; - return L.circleMarker([lat, lon], { - radius: 4, - color: markerColor, - zIndexOffset: 1000 - }).bindPopup(popupContent, { autoClose: false }); + if (userSettings.pointsRenderingMode === "simplified") { + return createSimplifiedMarkers(markersData, renderer); + } else { + return markersData.map((marker, index) => { + const [lat, lon] = marker; + const pointId = marker[6]; // ID is at index 6 + const markerColor = marker[5] < 0 ? "orange" : "blue"; + + return L.marker([lat, lon], { + icon: L.divIcon({ + className: 'custom-div-icon', + html: `
`, + iconSize: [8, 8], + iconAnchor: [4, 4] + }), + draggable: true, + autoPan: true, + pointIndex: index, + pointId: pointId, + originalLat: lat, + originalLng: lon, + markerData: marker, // Store the complete marker data + renderer: renderer + }).bindPopup(createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit)) + .on('dragstart', function(e) { + this.closePopup(); + }) + .on('drag', function(e) { + const newLatLng = e.target.getLatLng(); + const map = e.target._map; + const pointIndex = e.target.options.pointIndex; + const originalLat = e.target.options.originalLat; + const originalLng = e.target.options.originalLng; + // Find polylines by iterating through all map layers + map.eachLayer((layer) => { + // Check if this is a LayerGroup containing polylines + if (layer instanceof L.LayerGroup) { + layer.eachLayer((featureGroup) => { + if (featureGroup instanceof L.FeatureGroup) { + featureGroup.eachLayer((segment) => { + if (segment instanceof L.Polyline) { + const coords = segment.getLatLngs(); + const tolerance = 0.0000001; + let updated = false; + + // Check and update start point + if (Math.abs(coords[0].lat - originalLat) < tolerance && + Math.abs(coords[0].lng - originalLng) < tolerance) { + coords[0] = newLatLng; + updated = true; + } + + // Check and update end point + if (Math.abs(coords[1].lat - originalLat) < tolerance && + Math.abs(coords[1].lng - originalLng) < tolerance) { + coords[1] = newLatLng; + updated = true; + } + + // Only update if we found a matching endpoint + if (updated) { + segment.setLatLngs(coords); + segment.redraw(); + } + } + }); + } + }); + } + }); + + // Update the marker's original position for the next drag event + e.target.options.originalLat = newLatLng.lat; + e.target.options.originalLng = newLatLng.lng; + }) + .on('dragend', function(e) { + const newLatLng = e.target.getLatLng(); + const pointId = e.target.options.pointId; + const pointIndex = e.target.options.pointIndex; + const originalMarkerData = e.target.options.markerData; + + fetch(`/api/v1/points/${pointId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + point: { + latitude: newLatLng.lat.toString(), + longitude: newLatLng.lng.toString() + } + }) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + const map = e.target._map; + if (map && map.mapsController && map.mapsController.markers) { + const markers = map.mapsController.markers; + if (markers[pointIndex]) { + markers[pointIndex][0] = parseFloat(data.latitude); + markers[pointIndex][1] = parseFloat(data.longitude); + } + } + + // Create updated marker data array + const updatedMarkerData = [ + parseFloat(data.latitude), + parseFloat(data.longitude), + originalMarkerData[2], // battery + originalMarkerData[3], // altitude + originalMarkerData[4], // timestamp + originalMarkerData[5], // velocity + data.id, // id + originalMarkerData[7] // country + ]; + + // Update the marker's stored data + e.target.options.markerData = updatedMarkerData; + + // Update the popup content + if (this._popup) { + const updatedPopupContent = createPopupContent( + updatedMarkerData, + userSettings.timezone, + userSettings.distanceUnit + ); + this.setPopupContent(updatedPopupContent); + } + }) + .catch(error => { + console.error('Error updating point:', error); + this.setLatLng([e.target.options.originalLat, e.target.options.originalLng]); + alert('Failed to update point position. Please try again.'); + }); + }); }); } } -export function createSimplifiedMarkers(markersData) { +// Helper function to check if a point is connected to a polyline endpoint +function isConnectedToPoint(latLng, originalPoint, tolerance) { + // originalPoint is [lat, lng] array + const latMatch = Math.abs(latLng.lat - originalPoint[0]) < tolerance; + const lngMatch = Math.abs(latLng.lng - originalPoint[1]) < tolerance; + return latMatch && lngMatch; +} + +export function createSimplifiedMarkers(markersData, renderer) { const distanceThreshold = 50; // meters const timeThreshold = 20000; // milliseconds (3 seconds) @@ -30,7 +170,6 @@ export function createSimplifiedMarkers(markersData) { if (index === 0) return; // Skip the first marker const [prevLat, prevLon, prevTimestamp] = previousMarker; - const [currLat, currLon, currTimestamp] = currentMarker; const timeDiff = currTimestamp - prevTimestamp; const distance = haversineDistance(prevLat, prevLon, currLat, currLon, 'km') * 1000; // Convert km to meters @@ -47,6 +186,25 @@ export function createSimplifiedMarkers(markersData) { const [lat, lon] = marker; const popupContent = createPopupContent(marker); let markerColor = marker[5] < 0 ? "orange" : "blue"; - return L.circleMarker([lat, lon], { radius: 4, color: markerColor }).bindPopup(popupContent); + + // Use L.marker instead of L.circleMarker for better drag support + return L.marker([lat, lon], { + icon: L.divIcon({ + className: 'custom-div-icon', + html: `
`, + iconSize: [8, 8], + iconAnchor: [4, 4] + }), + draggable: true, + autoPan: true + }).bindPopup(popupContent) + .on('dragstart', function(e) { + this.closePopup(); + }) + .on('dragend', function(e) { + const newLatLng = e.target.getLatLng(); + this.setLatLng(newLatLng); + this.openPopup(); + }); }); } diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js index 92f4ea27..e48479d3 100644 --- a/app/javascript/maps/polylines.js +++ b/app/javascript/maps/polylines.js @@ -1,15 +1,152 @@ import { formatDate } from "../maps/helpers"; import { formatDistance } from "../maps/helpers"; -import { getUrlParameter } from "../maps/helpers"; import { minutesToDaysHoursMinutes } from "../maps/helpers"; import { haversineDistance } from "../maps/helpers"; -export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit) { - const originalStyle = { color: "blue", opacity: userSettings.routeOpacity, weight: 3 }; - const highlightStyle = { color: "yellow", opacity: 1, weight: 5 }; +function pointToLineDistance(point, lineStart, lineEnd) { + const x = point.lat; + const y = point.lng; + const x1 = lineStart.lat; + const y1 = lineStart.lng; + const x2 = lineEnd.lat; + const y2 = lineEnd.lng; - polyline.setStyle(originalStyle); + const A = x - x1; + const B = y - y1; + const C = x2 - x1; + const D = y2 - y1; + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let param = -1; + + if (lenSq !== 0) { + param = dot / lenSq; + } + + let xx, yy; + + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; + } + + const dx = x - xx; + const dy = y - yy; + + return Math.sqrt(dx * dx + dy * dy); +} + +export function calculateSpeed(point1, point2) { + if (!point1 || !point2 || !point1[4] || !point2[4]) { + console.warn('Invalid points for speed calculation:', { point1, point2 }); + return 0; + } + + const distanceKm = haversineDistance(point1[0], point1[1], point2[0], point2[1]); // in kilometers + const timeDiffSeconds = point2[4] - point1[4]; + + // Handle edge cases + if (timeDiffSeconds <= 0 || distanceKm <= 0) { + return 0; + } + + const speedKmh = (distanceKm / timeDiffSeconds) * 3600; // Convert to km/h + + // Cap speed at reasonable maximum (e.g., 150 km/h) + const MAX_SPEED = 150; + return Math.min(speedKmh, MAX_SPEED); +} + +// Optimize getSpeedColor by pre-calculating color stops +const colorStops = [ + { speed: 0, color: '#00ff00' }, // Stationary/very slow (green) + { speed: 15, color: '#00ffff' }, // Walking/jogging (cyan) + { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta) + { speed: 50, color: '#ffff00' }, // Urban driving (yellow) + { speed: 100, color: '#ff3300' } // Highway driving (red) +].map(stop => ({ + ...stop, + rgb: hexToRGB(stop.color) +})); + +export function getSpeedColor(speedKmh, useSpeedColors) { + if (!useSpeedColors) { + return '#0000ff'; + } + + // Find the appropriate color segment + for (let i = 1; i < colorStops.length; i++) { + if (speedKmh <= colorStops[i].speed) { + const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed); + const color1 = colorStops[i-1].rgb; + const color2 = colorStops[i].rgb; + + const r = Math.round(color1.r + (color2.r - color1.r) * ratio); + const g = Math.round(color1.g + (color2.g - color1.g) * ratio); + const b = Math.round(color1.b + (color2.b - color1.b) * ratio); + + return `rgb(${r}, ${g}, ${b})`; + } + } + + return colorStops[colorStops.length - 1].color; +} + +// Helper function to convert hex to RGB +function hexToRGB(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return { r, g, b }; +} + +// Add new function for batch processing +function processInBatches(items, batchSize, processFn) { + let index = 0; + const totalItems = items.length; + + function processNextBatch() { + const batchStartTime = performance.now(); + let processedInThisFrame = 0; + + // Process as many items as possible within our time budget + while (index < totalItems && processedInThisFrame < 500) { + const end = Math.min(index + batchSize, totalItems); + + // Ensure we're within bounds + for (let i = index; i < end; i++) { + if (items[i]) { // Add null check + processFn(items[i]); + } + } + + processedInThisFrame += (end - index); + index = end; + + if (performance.now() - batchStartTime > 32) { + break; + } + } + + if (index < totalItems) { + setTimeout(processNextBatch, 0); + } else { + // Only clear the array after all processing is complete + items.length = 0; + } + } + + processNextBatch(); +} + +export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, userSettings, distanceUnit) { const startPoint = polylineCoordinates[0]; const endPoint = polylineCoordinates[polylineCoordinates.length - 1]; @@ -28,70 +165,234 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" }); const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" }); - const isDebugMode = getUrlParameter("debug") === "true"; - - let popupContent = ` - Start: ${firstTimestamp}
- End: ${lastTimestamp}
- Duration: ${timeOnRoute}
- Total Distance: ${formatDistance(totalDistance, distanceUnit)}
- `; - - if (isDebugMode) { - const prevPoint = polylineCoordinates[0]; - const nextPoint = polylineCoordinates[polylineCoordinates.length - 1]; - const distanceToPrev = haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]); - const distanceToNext = haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]); - - const timeBetweenPrev = Math.round((startPoint[4] - prevPoint[4]) / 60); - const timeBetweenNext = Math.round((endPoint[4] - nextPoint[4]) / 60); - const pointsNumber = polylineCoordinates.length; - - popupContent += ` - Prev Route: ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away
- Next Route: ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away
- Points: ${pointsNumber}
- `; - } - - const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`); - const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(popupContent); + const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }); + const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }); let hoverPopup = null; + let clickedLayer = null; + + // Add events to both group and individual polylines + polylineGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + layer.on("mouseover", function (e) { + handleMouseOver(e); + }); + + layer.on("mouseout", function (e) { + handleMouseOut(e); + }); + + layer.on("click", function (e) { + handleClick(e); + }); + } + }); + + function handleMouseOver(e) { + // Handle both direct layer events and group propagated events + const layer = e.layer || e.target; + let speed = 0; + + if (layer instanceof L.Polyline) { + // Get the coordinates array from the layer + const coords = layer.getLatLngs(); + if (coords && coords.length >= 2) { + const startPoint = coords[0]; + const endPoint = coords[coords.length - 1]; + + // Find the corresponding markers for these coordinates + const startMarkerData = polylineCoordinates.find(m => + m[0] === startPoint.lat && m[1] === startPoint.lng + ); + const endMarkerData = polylineCoordinates.find(m => + m[0] === endPoint.lat && m[1] === endPoint.lng + ); + + // Calculate speed if we have both markers + if (startMarkerData && endMarkerData) { + speed = startMarkerData[5] || endMarkerData[5] || 0; + } + } + } + + // Don't apply hover styles if this is the clicked layer + if (!clickedLayer) { + // Apply style to all segments in the group + polylineGroup.eachLayer((segment) => { + if (segment instanceof L.Polyline) { + const newStyle = { + weight: 8, + opacity: 1 + }; + + // Only change color if speed-colored routes are not enabled + if (!userSettings.speed_colored_routes) { + newStyle.color = 'yellow'; // Highlight color + } + + segment.setStyle(newStyle); + } + }); + + startMarker.addTo(map); + endMarker.addTo(map); + + const popupContent = ` + Start: ${firstTimestamp}
+ End: ${lastTimestamp}
+ Duration: ${timeOnRoute}
+ Total Distance: ${formatDistance(totalDistance, distanceUnit)}
+ Current Speed: ${Math.round(speed)} km/h + `; + + if (hoverPopup) { + map.closePopup(hoverPopup); + } + + hoverPopup = L.popup() + .setLatLng(e.latlng) + .setContent(popupContent) + .openOn(map); + } + } + + function handleMouseOut(e) { + // If there's a clicked state, maintain it + if (clickedLayer && polylineGroup.clickedState) { + polylineGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + if (layer === clickedLayer || layer.options.originalPath === clickedLayer.options.originalPath) { + layer.setStyle(polylineGroup.clickedState.style); + } + } + }); + return; + } + + // Apply normal style only if there's no clicked layer + polylineGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + const originalStyle = { + weight: 3, + opacity: userSettings.route_opacity, + color: layer.options.originalColor + }; + layer.setStyle(originalStyle); + } + }); + + if (hoverPopup && !clickedLayer) { + map.closePopup(hoverPopup); + map.removeLayer(startMarker); + map.removeLayer(endMarker); + } + } + + function handleClick(e) { + const newClickedLayer = e.target; + + // If clicking the same route that's already clicked, do nothing + if (clickedLayer === newClickedLayer) { + return; + } + + // Store reference to previous clicked layer before updating + const previousClickedLayer = clickedLayer; + + // Update clicked layer reference + clickedLayer = newClickedLayer; + + // Reset previous clicked layer if it exists + if (previousClickedLayer) { + previousClickedLayer.setStyle({ + weight: 3, + opacity: userSettings.route_opacity, + color: previousClickedLayer.options.originalColor + }); + } + + // Define style for clicked state + const clickedStyle = { + weight: 8, + opacity: 1, + color: userSettings.speed_colored_routes ? clickedLayer.options.originalColor : 'yellow' + }; + + // Apply style to new clicked layer + clickedLayer.setStyle(clickedStyle); + clickedLayer.bringToFront(); + + // Update clicked state + polylineGroup.clickedState = { + layer: clickedLayer, + style: clickedStyle + }; - polyline.on("mouseover", function (e) { - polyline.setStyle(highlightStyle); startMarker.addTo(map); endMarker.addTo(map); - const latLng = e.latlng; + const popupContent = ` + Start: ${firstTimestamp}
+ End: ${lastTimestamp}
+ Duration: ${timeOnRoute}
+ Total Distance: ${formatDistance(totalDistance, distanceUnit)}
+ Current Speed: ${Math.round(clickedLayer.options.speed || 0)} km/h + `; + if (hoverPopup) { map.closePopup(hoverPopup); } + hoverPopup = L.popup() - .setLatLng(latLng) - .setContent(popupContent) - .openOn(map); + .setLatLng(e.latlng) + .setContent(popupContent) + .openOn(map); + + // Prevent the click event from propagating to the map + L.DomEvent.stopPropagation(e); + } + + // Reset highlight when clicking elsewhere on the map + map.on('click', function () { + if (clickedLayer) { + const clickedGroup = clickedLayer.polylineGroup || polylineGroup; + clickedGroup.eachLayer((layer) => { + if (layer instanceof L.Polyline) { + layer.setStyle({ + weight: 3, + opacity: userSettings.route_opacity, + color: layer.options.originalColor + }); + } + }); + clickedLayer = null; + clickedGroup.clickedState = null; + } + if (hoverPopup) { + map.closePopup(hoverPopup); + map.removeLayer(startMarker); + map.removeLayer(endMarker); + } }); - polyline.on("mouseout", function () { - polyline.setStyle(originalStyle); - map.closePopup(hoverPopup); - map.removeLayer(startMarker); - map.removeLayer(endMarker); - }); - - polyline.on("click", function () { - map.fitBounds(polyline.getBounds()); - }); - - // Close the popup when clicking elsewhere on the map - map.on("click", function () { - map.closePopup(hoverPopup); - }); + // Keep the original group events as a fallback + polylineGroup.on("mouseover", handleMouseOver); + polylineGroup.on("mouseout", handleMouseOut); + polylineGroup.on("click", handleClick); } export function createPolylinesLayer(markers, map, timezone, routeOpacity, userSettings, distanceUnit) { + // Create a custom pane for our polylines with higher z-index + if (!map.getPane('polylinesPane')) { + map.createPane('polylinesPane'); + map.getPane('polylinesPane').style.zIndex = 450; // Above the default overlay pane (400) + } + + const renderer = L.canvas({ + padding: 0.5, + pane: 'polylinesPane' + }); + const splitPolylines = []; let currentPolyline = []; const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500; @@ -119,27 +420,150 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS splitPolylines.push(currentPolyline); } - return L.layerGroup( - splitPolylines.map((polylineCoordinates) => { - const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]); - const polyline = L.polyline(latLngs, { - color: "blue", - opacity: 0.6, - weight: 3, - zIndexOffset: 400 + // Create the layer group with the polylines + const layerGroup = L.layerGroup( + splitPolylines.map((polylineCoordinates, groupIndex) => { + const segmentGroup = L.featureGroup(); + const segments = []; + + for (let i = 0; i < polylineCoordinates.length - 1; i++) { + const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]); + const color = getSpeedColor(speed, userSettings.speed_colored_routes); + + const segment = L.polyline( + [ + [polylineCoordinates[i][0], polylineCoordinates[i][1]], + [polylineCoordinates[i + 1][0], polylineCoordinates[i + 1][1]] + ], + { + renderer: renderer, + color: color, + originalColor: color, + opacity: routeOpacity, + weight: 3, + speed: speed, + interactive: true, + pane: 'polylinesPane', + bubblingMouseEvents: false + } + ); + + segments.push(segment); + segmentGroup.addLayer(segment); + } + + // Add mouseover/mouseout to the entire group + segmentGroup.on('mouseover', function(e) { + L.DomEvent.stopPropagation(e); + segments.forEach(segment => { + segment.setStyle({ + weight: 8, + opacity: 1 + }); + if (map.hasLayer(segment)) { + segment.bringToFront(); + } + }); }); - addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit); + segmentGroup.on('mouseout', function(e) { + L.DomEvent.stopPropagation(e); + segments.forEach(segment => { + segment.setStyle({ + weight: 3, + opacity: routeOpacity, + color: segment.options.originalColor + }); + }); + }); - return polyline; + // Make the group interactive + segmentGroup.options.interactive = true; + segmentGroup.options.bubblingMouseEvents = false; + + // Add the hover functionality to the group + addHighlightOnHover(segmentGroup, map, polylineCoordinates, userSettings, distanceUnit); + + return segmentGroup; }) - ).addTo(map); + ); + + // Add CSS to ensure our pane receives mouse events + const style = document.createElement('style'); + style.textContent = ` + .leaflet-polylinesPane-pane { + pointer-events: auto !important; + } + .leaflet-polylinesPane-pane canvas { + pointer-events: auto !important; + } + `; + document.head.appendChild(style); + + // Add to map and return + layerGroup.addTo(map); + + return layerGroup; } -export function updatePolylinesOpacity(polylinesLayer, opacity) { - polylinesLayer.eachLayer((layer) => { - if (layer instanceof L.Polyline) { - layer.setStyle({ opacity: opacity }); +export function updatePolylinesColors(polylinesLayer, useSpeedColors) { + const defaultStyle = { + color: '#0000ff', + originalColor: '#0000ff' + }; + + // More efficient segment collection + const segments = new Array(); + polylinesLayer.eachLayer(groupLayer => { + if (groupLayer instanceof L.LayerGroup) { + groupLayer.eachLayer(segment => { + if (segment instanceof L.Polyline) { + segments.push(segment); + } + }); + } + }); + + // Reuse style object to reduce garbage collection + const styleObj = {}; + + // Process segments in larger batches + processInBatches(segments, 200, (segment) => { + try { + if (!useSpeedColors) { + segment.setStyle(defaultStyle); + return; + } + + const speed = segment.options.speed || 0; + const newColor = getSpeedColor(speed, true); + + // Reuse style object + styleObj.color = newColor; + styleObj.originalColor = newColor; + segment.setStyle(styleObj); + } catch (error) { + console.error('Error processing segment:', error); } }); } + +export function updatePolylinesOpacity(polylinesLayer, opacity) { + const segments = []; + + // Collect all segments first + polylinesLayer.eachLayer((groupLayer) => { + if (groupLayer instanceof L.LayerGroup) { + groupLayer.eachLayer((segment) => { + if (segment instanceof L.Polyline) { + segments.push(segment); + } + }); + } + }); + + // Process segments in batches of 50 + processInBatches(segments, 50, (segment) => { + segment.setStyle({ opacity: opacity }); + }); +} diff --git a/app/javascript/maps/popups.js b/app/javascript/maps/popups.js index 34a71224..cba49a22 100644 --- a/app/javascript/maps/popups.js +++ b/app/javascript/maps/popups.js @@ -8,12 +8,15 @@ export function createPopupContent(marker, timezone, distanceUnit) { marker[3] = marker[3] * 3.28084; } + // convert marker[5] from m/s to km/h and round to nearest integer + marker[5] = Math.round(marker[5] * 3.6); + return ` Timestamp: ${formatDate(marker[4], timezone)}
Latitude: ${marker[0]}
Longitude: ${marker[1]}
Altitude: ${marker[3]}m
- Velocity: ${marker[5]}km/h
+ Speed: ${marker[5]}km/h
Battery: ${marker[2]}%
Id: ${marker[6]}
[Delete] diff --git a/app/jobs/cache/preheating_job.rb b/app/jobs/cache/preheating_job.rb index bdf3ea99..c43a50b3 100644 --- a/app/jobs/cache/preheating_job.rb +++ b/app/jobs/cache/preheating_job.rb @@ -5,7 +5,11 @@ class Cache::PreheatingJob < ApplicationJob def perform User.find_each do |user| - Rails.cache.write("dawarich/user_#{user.id}_years_tracked", user.years_tracked, expires_in: 1.day) + Rails.cache.write( + "dawarich/user_#{user.id}_years_tracked", + user.years_tracked, + expires_in: 1.day + ) end end end diff --git a/app/jobs/import/google_takeout_job.rb b/app/jobs/import/google_takeout_job.rb index d962a304..02702cf7 100644 --- a/app/jobs/import/google_takeout_job.rb +++ b/app/jobs/import/google_takeout_job.rb @@ -4,11 +4,10 @@ class Import::GoogleTakeoutJob < ApplicationJob queue_as :imports sidekiq_options retry: false - def perform(import_id, json_string) + def perform(import_id, locations, current_index) + locations_batch = Oj.load(locations) import = Import.find(import_id) - json = Oj.load(json_string) - - GoogleMaps::RecordsParser.new(import).call(json) + GoogleMaps::RecordsImporter.new(import, current_index).call(locations_batch) end end diff --git a/app/jobs/points/create_job.rb b/app/jobs/points/create_job.rb new file mode 100644 index 00000000..964c50f7 --- /dev/null +++ b/app/jobs/points/create_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Points::CreateJob < ApplicationJob + queue_as :default + + def perform(params, user_id) + data = Points::Params.new(params, user_id).call + + data.each_slice(1000) do |location_batch| + Point.upsert_all( + location_batch, + unique_by: %i[latitude longitude timestamp user_id], + returning: false + ) + end + end +end diff --git a/app/jobs/reverse_geocoding_job.rb b/app/jobs/reverse_geocoding_job.rb index dc49d2a2..8c2a232b 100644 --- a/app/jobs/reverse_geocoding_job.rb +++ b/app/jobs/reverse_geocoding_job.rb @@ -4,7 +4,7 @@ class ReverseGeocodingJob < ApplicationJob queue_as :reverse_geocoding def perform(klass, id) - return unless REVERSE_GEOCODING_ENABLED + return unless DawarichSettings.reverse_geocoding_enabled? rate_limit_for_photon_api @@ -18,8 +18,8 @@ class ReverseGeocodingJob < ApplicationJob end def rate_limit_for_photon_api - return unless PHOTON_API_HOST == 'photon.komoot.io' + return unless DawarichSettings.photon_enabled? - sleep 1 if PHOTON_API_HOST == 'photon.komoot.io' + sleep 1 if DawarichSettings.photon_uses_komoot_io? end end diff --git a/app/jobs/visit_suggesting_job.rb b/app/jobs/visit_suggesting_job.rb index 06883b64..b1a3e13d 100644 --- a/app/jobs/visit_suggesting_job.rb +++ b/app/jobs/visit_suggesting_job.rb @@ -7,6 +7,10 @@ class VisitSuggestingJob < ApplicationJob def perform(user_ids: [], start_at: 1.day.ago, end_at: Time.current) users = user_ids.any? ? User.where(id: user_ids) : User.all - users.find_each { Visits::Suggest.new(_1, start_at:, end_at:).call } + users.find_each do |user| + next if user.tracked_points.empty? + + Visits::Suggest.new(user, start_at:, end_at:).call + end end end diff --git a/app/models/import.rb b/app/models/import.rb index f396c555..045e8b5f 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -4,6 +4,8 @@ class Import < ApplicationRecord belongs_to :user has_many :points, dependent: :destroy + delegate :count, to: :points, prefix: true + include ImportUploader::Attachment(:raw) enum :source, { diff --git a/app/models/place.rb b/app/models/place.rb index a4ff8970..2ed0aa2d 100644 --- a/app/models/place.rb +++ b/app/models/place.rb @@ -13,7 +13,7 @@ class Place < ApplicationRecord enum :source, { manual: 0, photon: 1 } def async_reverse_geocode - return unless REVERSE_GEOCODING_ENABLED + return unless DawarichSettings.reverse_geocoding_enabled? ReverseGeocodingJob.perform_later(self.class.to_s, id) end diff --git a/app/models/point.rb b/app/models/point.rb index 26984c3e..f28b8043 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -8,7 +8,11 @@ class Point < ApplicationRecord belongs_to :user validates :latitude, :longitude, :timestamp, presence: true - + validates :timestamp, uniqueness: { + scope: %i[latitude longitude user_id], + message: 'already has a point at this location and time for this user', + index: true + } enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true enum :trigger, { unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3, @@ -33,9 +37,8 @@ class Point < ApplicationRecord Time.zone.at(timestamp) end - def async_reverse_geocode(force: false) - return unless REVERSE_GEOCODING_ENABLED - return if import_id.present? && !force + def async_reverse_geocode + return unless DawarichSettings.reverse_geocoding_enabled? ReverseGeocodingJob.perform_later(self.class.to_s, id) end diff --git a/app/models/user.rb b/app/models/user.rb index 0dd32104..b3112130 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -71,15 +71,23 @@ class User < ApplicationRecord def years_tracked Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do - tracked_points - .pluck(:timestamp) - .map { |ts| Time.zone.at(ts) } - .group_by(&:year) - .transform_values do |dates| - dates.map { |date| date.strftime('%b') }.uniq.sort - end + # Use select_all for better performance with large datasets + sql = <<-SQL + SELECT DISTINCT + EXTRACT(YEAR FROM TO_TIMESTAMP(timestamp)) AS year, + TO_CHAR(TO_TIMESTAMP(timestamp), 'Mon') AS month + FROM points + WHERE user_id = #{id} + ORDER BY year DESC, month ASC + SQL + + result = ActiveRecord::Base.connection.select_all(sql) + + result + .map { |r| [r['year'].to_i, r['month']] } + .group_by { |year, _| year } + .transform_values { |year_data| year_data.map { |_, month| month } } .map { |year, months| { year: year, months: months } } - .sort_by { |entry| -entry[:year] } # Sort in descending order end end diff --git a/app/models/visit.rb b/app/models/visit.rb index ddd6124f..f46d219b 100644 --- a/app/models/visit.rb +++ b/app/models/visit.rb @@ -40,7 +40,7 @@ class Visit < ApplicationRecord end def async_reverse_geocode - return unless REVERSE_GEOCODING_ENABLED + return unless DawarichSettings.reverse_geocoding_enabled? return if place.blank? ReverseGeocodingJob.perform_later('place', place_id) diff --git a/app/services/cache/clean.rb b/app/services/cache/clean.rb index 46a69e0b..15647b99 100644 --- a/app/services/cache/clean.rb +++ b/app/services/cache/clean.rb @@ -4,6 +4,7 @@ class Cache::Clean class << self def call Rails.logger.info('Cleaning cache...') + delete_control_flag delete_version_cache delete_years_tracked_cache Rails.logger.info('Cache cleaned') @@ -11,6 +12,10 @@ class Cache::Clean private + def delete_control_flag + Rails.cache.delete('cache_jobs_scheduled') + end + def delete_version_cache Rails.cache.delete(CheckAppVersion::VERSION_CACHE_KEY) end diff --git a/app/services/check_app_version.rb b/app/services/check_app_version.rb index 3ae5ed76..bb2fd449 100644 --- a/app/services/check_app_version.rb +++ b/app/services/check_app_version.rb @@ -17,7 +17,10 @@ class CheckAppVersion def latest_version Rails.cache.fetch(VERSION_CACHE_KEY, expires_in: 6.hours) do - JSON.parse(Net::HTTP.get(URI.parse(@repo_url)))[0]['name'] + versions = JSON.parse(Net::HTTP.get(URI.parse(@repo_url))) + # Find first version that contains only numbers and dots + release_version = versions.find { |v| v['name'].match?(/^\d+\.\d+\.\d+$/) } + release_version ? release_version['name'] : APP_VERSION end end end diff --git a/app/services/google_maps/records_importer.rb b/app/services/google_maps/records_importer.rb new file mode 100644 index 00000000..c7edfb1f --- /dev/null +++ b/app/services/google_maps/records_importer.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +class GoogleMaps::RecordsImporter + include Imports::Broadcaster + + BATCH_SIZE = 1000 + attr_reader :import, :current_index + + def initialize(import, current_index = 0) + @import = import + @batch = [] + @current_index = current_index + end + + def call(locations) + Array(locations).each_slice(BATCH_SIZE) do |location_batch| + batch = location_batch.map { prepare_location_data(_1) } + bulk_insert_points(batch) + broadcast_import_progress(import, current_index) + end + end + + private + + # rubocop:disable Metrics/MethodLength + def prepare_location_data(location) + { + latitude: location['latitudeE7'].to_f / 10**7, + longitude: location['longitudeE7'].to_f / 10**7, + timestamp: parse_timestamp(location), + altitude: location['altitude'], + velocity: location['velocity'], + raw_data: location, + topic: 'Google Maps Timeline Export', + tracker_id: 'google-maps-timeline-export', + import_id: @import.id, + user_id: @import.user_id, + created_at: Time.current, + updated_at: Time.current + } + end + # rubocop:enable Metrics/MethodLength + + def bulk_insert_points(batch) + unique_batch = deduplicate_batch(batch) + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + unique_batch, + unique_by: %i[latitude longitude timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + rescue StandardError => e + create_notification("Failed to process location batch: #{e.message}") + end + + def deduplicate_batch(batch) + batch.uniq do |record| + [ + record[:latitude].round(7), + record[:longitude].round(7), + record[:timestamp], + record[:user_id] + ] + end + end + + def parse_timestamp(location) + Timestamps.parse_timestamp( + location['timestamp'] || location['timestampMs'] + ) + end + + def create_notification(message) + Notification.create!( + user: @import.user, + title: 'Google\'s Records.json Import Error', + content: message, + kind: :error + ) + end +end diff --git a/app/services/google_maps/records_parser.rb b/app/services/google_maps/records_parser.rb deleted file mode 100644 index 04ee4621..00000000 --- a/app/services/google_maps/records_parser.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -class GoogleMaps::RecordsParser - attr_reader :import - - def initialize(import) - @import = import - end - - def call(json) - data = parse_json(json) - - return if Point.exists?( - latitude: data[:latitude], - longitude: data[:longitude], - timestamp: data[:timestamp], - user_id: import.user_id - ) - - Point.create( - latitude: data[:latitude], - longitude: data[:longitude], - timestamp: data[:timestamp], - raw_data: data[:raw_data], - topic: 'Google Maps Timeline Export', - tracker_id: 'google-maps-timeline-export', - import_id: import.id, - user_id: import.user_id - ) - end - - private - - def parse_json(json) - { - latitude: json['latitudeE7'].to_f / 10**7, - longitude: json['longitudeE7'].to_f / 10**7, - timestamp: Timestamps.parse_timestamp(json['timestamp'] || json['timestampMs']), - altitude: json['altitude'], - velocity: json['velocity'], - raw_data: json - } - end -end diff --git a/app/services/gpx/track_parser.rb b/app/services/gpx/track_parser.rb index 65cbc3be..10f13983 100644 --- a/app/services/gpx/track_parser.rb +++ b/app/services/gpx/track_parser.rb @@ -15,7 +15,7 @@ class Gpx::TrackParser tracks = json['gpx']['trk'] tracks_arr = tracks.is_a?(Array) ? tracks : [tracks] - tracks_arr.map { parse_track(_1) }.flatten.each.with_index(1) do |point, index| + tracks_arr.map { parse_track(_1) }.flatten.compact.each.with_index(1) do |point, index| create_point(point, index) end end @@ -23,6 +23,8 @@ class Gpx::TrackParser private def parse_track(track) + return if track['trkseg'].blank? + segments = track['trkseg'] segments_array = segments.is_a?(Array) ? segments : [segments] diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb index 59baa496..0d3f6e1f 100644 --- a/app/services/immich/request_photos.rb +++ b/app/services/immich/request_photos.rb @@ -34,7 +34,8 @@ class Immich::RequestPhotos immich_api_base_url, headers: headers, body: request_body(page) ).body ) - + Rails.logger.debug('==== IMMICH RESPONSE ====') + Rails.logger.debug(response) items = response.dig('assets', 'items') break if items.blank? diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb index 78ddf9cd..16374170 100644 --- a/app/services/imports/create.rb +++ b/app/services/imports/create.rb @@ -14,8 +14,7 @@ class Imports::Create create_import_finished_notification(import, user) schedule_stats_creating(user.id) - schedule_visit_suggesting(user.id, import) - schedule_reverse_geocoding(user.id) + # schedule_visit_suggesting(user.id, import) # Disabled until places & visits are reworked rescue StandardError => e create_import_failed_notification(import, user, e) end @@ -48,10 +47,6 @@ class Imports::Create VisitSuggestingJob.perform_later(user_ids: [user_id], start_at:, end_at:) end - def schedule_reverse_geocoding(user_id) - EnqueueBackgroundJob.perform_later('continue_reverse_geocoding', user_id) - end - def create_import_finished_notification(import, user) Notifications::Create.new( user:, diff --git a/app/services/jobs/create.rb b/app/services/jobs/create.rb index 6e301146..bbbcb15c 100644 --- a/app/services/jobs/create.rb +++ b/app/services/jobs/create.rb @@ -22,7 +22,7 @@ class Jobs::Create end points.find_each(batch_size: 1_000) do |point| - point.async_reverse_geocode(force: true) + point.async_reverse_geocode end end end diff --git a/app/services/points/params.rb b/app/services/points/params.rb new file mode 100644 index 00000000..8c1b8a51 --- /dev/null +++ b/app/services/points/params.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Points::Params + attr_reader :data, :points, :user_id + + def initialize(json, user_id) + @data = json.with_indifferent_access + @points = @data[:locations] + @user_id = user_id + end + + def call + points.map do |point| + next unless params_valid?(point) + + { + latitude: point[:geometry][:coordinates][1], + longitude: point[:geometry][:coordinates][0], + battery_status: point[:properties][:battery_state], + battery: battery_level(point[:properties][:battery_level]), + timestamp: DateTime.parse(point[:properties][:timestamp]), + altitude: point[:properties][:altitude], + tracker_id: point[:properties][:device_id], + velocity: point[:properties][:speed], + ssid: point[:properties][:wifi], + accuracy: point[:properties][:horizontal_accuracy], + vertical_accuracy: point[:properties][:vertical_accuracy], + course_accuracy: point[:properties][:course_accuracy], + course: point[:properties][:course], + raw_data: point, + user_id: user_id + } + end.compact + end + + private + + def battery_level(level) + value = (level.to_f * 100).to_i + + value.positive? ? value : nil + end + + def params_valid?(point) + point[:geometry].present? && + point[:geometry][:coordinates].present? && + point.dig(:properties, :timestamp).present? + end +end diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 0ed4e236..9eec9de4 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -12,8 +12,8 @@ class ReverseGeocoding::Places::FetchData end def call - if ::PHOTON_API_HOST.blank? - Rails.logger.warn('PHOTON_API_HOST is not set') + unless DawarichSettings.reverse_geocoding_enabled? + Rails.logger.warn('Reverse geocoding is not enabled') return end diff --git a/app/services/tasks/imports/google_records.rb b/app/services/tasks/imports/google_records.rb index 8f8839e3..70b5d389 100644 --- a/app/services/tasks/imports/google_records.rb +++ b/app/services/tasks/imports/google_records.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -# This class is named based on Google Takeout's Records.json file, -# the main source of user's location history data. +# This class is named based on Google Takeout's Records.json file class Tasks::Imports::GoogleRecords + BATCH_SIZE = 1000 # Adjust based on your needs + def initialize(file_path, user_email) @file_path = file_path @user = User.find_by(email: user_email) @@ -14,10 +15,11 @@ class Tasks::Imports::GoogleRecords import_id = create_import log_start - file_content = read_file - json_data = Oj.load(file_content) - schedule_import_jobs(json_data, import_id) + process_file_in_batches(import_id) log_success + rescue Oj::ParseError => e + Rails.logger.error("JSON parsing error: #{e.message}") + raise end private @@ -26,14 +28,25 @@ class Tasks::Imports::GoogleRecords @user.imports.create(name: @file_path, source: :google_records).id end - def read_file - File.read(@file_path) - end + def process_file_in_batches(import_id) + batch = [] + index = 0 - def schedule_import_jobs(json_data, import_id) - json_data['locations'].each do |json| - Import::GoogleTakeoutJob.perform_later(import_id, json.to_json) + Oj.load_file(@file_path, mode: :compat) do |record| + next unless record.is_a?(Hash) && record['locations'] + + record['locations'].each do |location| + batch << location + + next unless batch.size >= BATCH_SIZE + + index += BATCH_SIZE + Import::GoogleTakeoutJob.perform_later(import_id, Oj.dump(batch), index) + batch = [] + end end + + Import::GoogleTakeoutJob.perform_later(import_id, Oj.dump(batch), index) if batch.any? end def log_start diff --git a/app/services/visits/suggest.rb b/app/services/visits/suggest.rb index f68bffce..4d02a45c 100644 --- a/app/services/visits/suggest.rb +++ b/app/services/visits/suggest.rb @@ -20,7 +20,7 @@ class Visits::Suggest create_visits_notification(user) - return nil unless reverse_geocoding_enabled? + return nil unless DawarichSettings.reverse_geocoding_enabled? reverse_geocode(visits) end @@ -68,10 +68,6 @@ class Visits::Suggest visits.each(&:async_reverse_geocode) end - def reverse_geocoding_enabled? - ::REVERSE_GEOCODING_ENABLED && ::PHOTON_API_HOST.present? - end - def create_visits_notification(user) content = <<~CONTENT New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the Visits page. diff --git a/app/views/map/_settings_modals.html.erb b/app/views/map/_settings_modals.html.erb index 09ddd165..5a36b807 100644 --- a/app/views/map/_settings_modals.html.erb +++ b/app/views/map/_settings_modals.html.erb @@ -112,3 +112,32 @@ + + + diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 7e36c225..d3c39f80 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -51,6 +51,8 @@ data-api_key="<%= current_user.api_key %>" data-user_settings=<%= current_user.settings.to_json %> data-coordinates="<%= @coordinates %>" + data-distance="<%= @distance %>" + data-points_number="<%= @points_number %>" data-timezone="<%= Rails.configuration.time_zone %>">
diff --git a/app/views/stats/_stat.html.erb b/app/views/stats/_stat.html.erb index f8e59e04..8ac892f2 100644 --- a/app/views/stats/_stat.html.erb +++ b/app/views/stats/_stat.html.erb @@ -12,7 +12,7 @@

<%= stat.distance %><%= DISTANCE_UNIT %>

- <% if REVERSE_GEOCODING_ENABLED %> + <% if DawarichSettings.reverse_geocoding_enabled? %>
<%= countries_and_cities_stat_for_month(stat) %>
diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index d67037de..28da8b9f 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -16,7 +16,7 @@
Geopoints tracked
- <% if REVERSE_GEOCODING_ENABLED %> + <% if DawarichSettings.reverse_geocoding_enabled? %> <%= render 'stats/reverse_geocoding_stats' %> <% end %> @@ -39,7 +39,7 @@ <%= number_with_delimiter year_distance_stat(year, current_user) %><%= DISTANCE_UNIT %> <% end %>

- <% if REVERSE_GEOCODING_ENABLED %> + <% if DawarichSettings.reverse_geocoding_enabled? %>
<%= countries_and_cities_stat_for_year(year, stats) %>
diff --git a/config/database.yml b/config/database.yml index 1977b027..fca7a51c 100644 --- a/config/database.yml +++ b/config/database.yml @@ -20,4 +20,3 @@ test: production: <<: *default database: <%= ENV['DATABASE_NAME'] || 'dawarich_production' %> - url: <%= ENV['DATABASE_URL'] %> diff --git a/config/environment.rb b/config/environment.rb index 7e5c58f9..9d36606c 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -6,8 +6,11 @@ require_relative 'application' # Initialize the Rails application. Rails.application.initialize! -# Clear the cache -Cache::CleaningJob.perform_later +# Use an atomic operation to ensure one-time execution +if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true) + # Clear the cache + Cache::CleaningJob.perform_later -# Preheat the cache -Cache::PreheatingJob.perform_later + # Preheat the cache + Cache::PreheatingJob.perform_later +end diff --git a/config/environments/development.rb b/config/environments/development.rb index dd27f7bd..29b9a038 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -86,11 +86,11 @@ Rails.application.configure do # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true - config.action_mailer.default_url_options = { host: ENV.fetch('APPLICATION_HOST', 'localhost'), port: 3000 } - config.hosts << ENV.fetch('APPLICATION_HOST', 'localhost') - hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost') - config.hosts.concat(hosts.split(',')) if hosts.present? + hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',') + + config.action_mailer.default_url_options = { host: hosts.first, port: 3000 } + config.hosts.concat(hosts) if hosts.present? config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https' diff --git a/config/environments/production.rb b/config/environments/production.rb index 8b1a2d2a..4e8f5661 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -29,7 +29,11 @@ Rails.application.configure do # config.assets.css_compressor = :sass # Do not fallback to assets pipeline if a precompiled asset is missed. - config.assets.compile = false + config.assets.compile = true + + config.assets.content_type = { + geojson: 'application/geo+json' + } # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" @@ -53,12 +57,12 @@ Rails.application.configure do # config.assume_ssl = true # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. - config.force_ssl = true + config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https' - # Log to STDOUT by default - config.logger = ActiveSupport::Logger.new($stdout) - .tap { |logger| logger.formatter = ::Logger::Formatter.new } - .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + # Direct logs to STDOUT + config.logger = Logger.new($stdout) + config.lograge.enabled = true + config.lograge.formatter = Lograge::Formatters::Json.new # Prepend all log lines with the following tags. config.log_tags = [:request_id] @@ -99,4 +103,8 @@ Rails.application.configure do # ] # Skip DNS rebinding protection for the default health check endpoint. # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } + hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',') + + config.action_mailer.default_url_options = { host: hosts.first, port: 3000 } + config.hosts.concat(hosts) if hosts.present? end diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb index d8cc2d81..ce760238 100644 --- a/config/initializers/01_constants.rb +++ b/config/initializers/01_constants.rb @@ -1,10 +1,17 @@ # frozen_string_literal: true MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i -REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true' -PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil) -PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true' DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym + APP_VERSION = File.read('.app_version').strip + TELEMETRY_STRING = Base64.encode64('IjVFvb8j3P9-ArqhSGav9j8YcJaQiuNIzkfOPKQDk2lvKXqb8t1NSRv50oBkaKtlrB_ZRzO9NdurpMtncV_HYQ==') TELEMETRY_URL = 'https://influxdb2.frey.today/api/v2/write' + +# Reverse geocoding settings +PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil) +PHOTON_API_KEY = ENV.fetch('PHOTON_API_KEY', nil) +PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true' + +GEOAPIFY_API_KEY = ENV.fetch('GEOAPIFY_API_KEY', nil) +# /Reverse geocoding settings diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb new file mode 100644 index 00000000..87cf4817 --- /dev/null +++ b/config/initializers/03_dawarich_settings.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class DawarichSettings + class << self + def reverse_geocoding_enabled? + @reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? + end + + def photon_enabled? + @photon_enabled ||= PHOTON_API_HOST.present? + end + + def photon_uses_komoot_io? + @photon_uses_komoot_io ||= PHOTON_API_HOST == 'photon.komoot.io' + end + + def geoapify_enabled? + @geoapify_enabled ||= GEOAPIFY_API_KEY.present? + end + end +end diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb index 837fb394..eb1a7fc4 100644 --- a/config/initializers/geocoder.rb +++ b/config/initializers/geocoder.rb @@ -12,11 +12,13 @@ settings = { } } -if defined?(PHOTON_API_HOST) +if PHOTON_API_HOST.present? settings[:lookup] = :photon settings[:photon] = { use_https: PHOTON_API_USE_HTTPS, host: PHOTON_API_HOST } + settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if defined?(PHOTON_API_KEY) +elsif GEOAPIFY_API_KEY.present? + settings[:lookup] = :geoapify + settings[:api_key] = GEOAPIFY_API_KEY end -settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if defined?(PHOTON_API_KEY) - Geocoder.configure(settings) diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 9e54f2ab..ab3f00c5 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -2,6 +2,7 @@ Sidekiq.configure_server do |config| config.redis = { url: ENV['REDIS_URL'] } + config.logger = Sidekiq::Logger.new($stdout) if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true' require 'prometheus_exporter/instrumentation' @@ -27,4 +28,4 @@ Sidekiq.configure_client do |config| config.redis = { url: ENV['REDIS_URL'] } end -Sidekiq::Queue['reverse_geocoding'].limit = 1 if Sidekiq.server? && PHOTON_API_HOST == 'photon.komoot.io' +Sidekiq::Queue['reverse_geocoding'].limit = 1 if Sidekiq.server? && DawarichSettings.photon_uses_komoot_io? diff --git a/config/routes.rb b/config/routes.rb index 8d28efde..9a6a38ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,9 +65,10 @@ Rails.application.routes.draw do get 'health', to: 'health#index' patch 'settings', to: 'settings#update' get 'settings', to: 'settings#index' + get 'users/me', to: 'users#me' resources :areas, only: %i[index create update destroy] - resources :points, only: %i[index destroy] + resources :points, only: %i[index create update destroy] resources :visits, only: %i[update] resources :stats, only: :index diff --git a/db/data/20240610170930_remove_points_without_coordinates.rb b/db/data/20240610170930_remove_points_without_coordinates.rb index b8647672..85ef7684 100644 --- a/db/data/20240610170930_remove_points_without_coordinates.rb +++ b/db/data/20240610170930_remove_points_without_coordinates.rb @@ -12,7 +12,7 @@ class RemovePointsWithoutCoordinates < ActiveRecord::Migration[7.1] Rails.logger.info 'Points without coordinates removed.' - BulkStatsCalculatingJob.perform_later(User.pluck(:id)) + BulkStatsCalculatingJob.perform_later end def down diff --git a/db/data/20241206163450_create_telemetry_notification.rb b/db/data/20241206163450_create_telemetry_notification.rb index bd5f6dd2..2d9f93d4 100644 --- a/db/data/20241206163450_create_telemetry_notification.rb +++ b/db/data/20241206163450_create_telemetry_notification.rb @@ -2,11 +2,12 @@ class CreateTelemetryNotification < ActiveRecord::Migration[7.2] def up - User.find_each do |user| - Notifications::Create.new( - user:, kind: :info, title: 'Telemetry enabled', content: notification_content - ).call - end + # TODO: Remove + # User.find_each do |user| + # Notifications::Create.new( + # user:, kind: :info, title: 'Telemetry enabled', content: notification_content + # ).call + # end end def down diff --git a/db/data/20250104204852_create_photon_load_notification.rb b/db/data/20250104204852_create_photon_load_notification.rb new file mode 100644 index 00000000..0b8009fe --- /dev/null +++ b/db/data/20250104204852_create_photon_load_notification.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class CreatePhotonLoadNotification < ActiveRecord::Migration[8.0] + def up + User.find_each do |user| + Notifications::Create.new( + user:, kind: :info, title: '⚠️ Photon API is under heavy load', content: notification_content + ).call + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end + + private + + def notification_content + <<~CONTENT +

+ A few days ago @lonvia, maintainer of https://photon.komoot.io, the reverse-geocoding API service that Dawarich is using by default, reached me to highlight a problem: Dawarich makes too many requests to https://photon.komoot.io, even with recently introduced rate-limiting to prevent more than 1 request per second. +

+ +
+ +

+ Photon is a great service and Dawarich wouldn't be what it is now without it, but I have to ask all Dawarich users that are running it on their hardware to either switch to a Photon instance hosted by me (Freika) or strongly consider hosting their own Photon instance. Thanks to @rtuszik, it's pretty much docker compose up -d. The documentation on the website will be soon updated to also encourage setting up your own Photon instance. More reverse geocoding options will be added in the future.

+
+ +

Let's decrease load on https://photon.komoot.io together!

+ +
+ +

Thank you.

+ CONTENT + end +end diff --git a/db/data/20250120154554_remove_duplicate_points.rb b/db/data/20250120154554_remove_duplicate_points.rb new file mode 100644 index 00000000..2eaa2e4c --- /dev/null +++ b/db/data/20250120154554_remove_duplicate_points.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class RemoveDuplicatePoints < ActiveRecord::Migration[8.0] + def up + # Find duplicate groups using a subquery + duplicate_groups = + Point.select('latitude, longitude, timestamp, user_id, COUNT(*) as count') + .group('latitude, longitude, timestamp, user_id') + .having('COUNT(*) > 1') + + puts "Duplicate groups found: #{duplicate_groups.length}" + + duplicate_groups.each do |group| + points = Point.where( + latitude: group.latitude, + longitude: group.longitude, + timestamp: group.timestamp, + user_id: group.user_id + ).order(id: :asc) + + # Keep the latest record and destroy all others + latest = points.last + points.where.not(id: latest.id).destroy_all + end + end + + def down + # This migration cannot be reversed + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/data_schema.rb b/db/data_schema.rb index e14cf9d4..56adf2dc 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 20241107112451) +DataMigrate::Data.define(version: 20250120154554) diff --git a/db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb b/db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb new file mode 100644 index 00000000..78e1feb0 --- /dev/null +++ b/db/migrate/20250120152014_add_course_and_course_accuracy_to_points.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddCourseAndCourseAccuracyToPoints < ActiveRecord::Migration[8.0] + def change + add_column :points, :course, :decimal, precision: 8, scale: 5 + add_column :points, :course_accuracy, :decimal, precision: 8, scale: 5 + end +end diff --git a/db/migrate/20250120152540_add_external_track_id_to_points.rb b/db/migrate/20250120152540_add_external_track_id_to_points.rb new file mode 100644 index 00000000..4531b19d --- /dev/null +++ b/db/migrate/20250120152540_add_external_track_id_to_points.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddExternalTrackIdToPoints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :points, :external_track_id, :string + + add_index :points, :external_track_id, algorithm: :concurrently + end +end diff --git a/db/migrate/20250120154555_add_unique_index_to_points.rb b/db/migrate/20250120154555_add_unique_index_to_points.rb new file mode 100644 index 00000000..0a408cf7 --- /dev/null +++ b/db/migrate/20250120154555_add_unique_index_to_points.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class AddUniqueIndexToPoints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + return if index_exists?( + :points, %i[latitude longitude timestamp user_id], + name: 'unique_points_lat_long_timestamp_user_id_index' + ) + + add_index :points, %i[latitude longitude timestamp user_id], + unique: true, + name: 'unique_points_lat_long_timestamp_user_id_index', + algorithm: :concurrently + end + + def down + return unless index_exists?( + :points, %i[latitude longitude timestamp user_id], + name: 'unique_points_lat_long_timestamp_user_id_index' + ) + + remove_index :points, %i[latitude longitude timestamp user_id], + name: 'unique_points_lat_long_timestamp_user_id_index' + end +end diff --git a/db/schema.rb b/db/schema.rb index 0b55b25e..ebf1007e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,9 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2024_12_26_202831) do +ActiveRecord::Schema[8.0].define(version: 2025_01_20_154555) do # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" create_table "action_text_rich_texts", force: :cascade do |t| t.string "name", null: false @@ -156,14 +156,19 @@ ActiveRecord::Schema[8.0].define(version: 2024_12_26_202831) do t.jsonb "geodata", default: {}, null: false t.bigint "visit_id" t.datetime "reverse_geocoded_at" + t.decimal "course", precision: 8, scale: 5 + t.decimal "course_accuracy", precision: 8, scale: 5 + t.string "external_track_id" t.index ["altitude"], name: "index_points_on_altitude" t.index ["battery"], name: "index_points_on_battery" t.index ["battery_status"], name: "index_points_on_battery_status" t.index ["city"], name: "index_points_on_city" t.index ["connection"], name: "index_points_on_connection" t.index ["country"], name: "index_points_on_country" + t.index ["external_track_id"], name: "index_points_on_external_track_id" t.index ["geodata"], name: "index_points_on_geodata", using: :gin t.index ["import_id"], name: "index_points_on_import_id" + t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_lat_long_timestamp_user_id_index", unique: true t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude" t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at" t.index ["timestamp"], name: "index_points_on_timestamp" diff --git a/dev-docker-entrypoint.sh b/dev-docker-entrypoint.sh deleted file mode 100644 index 385b50da..00000000 --- a/dev-docker-entrypoint.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/bin/sh - -unset BUNDLE_PATH -unset BUNDLE_BIN - -set -e - -echo "Environment: $RAILS_ENV" - -# set env var defaults -DATABASE_HOST=${DATABASE_HOST:-"dawarich_db"} -DATABASE_PORT=${DATABASE_PORT:-5432} -DATABASE_USERNAME=${DATABASE_USERNAME:-"postgres"} -DATABASE_PASSWORD=${DATABASE_PASSWORD:-"password"} -DATABASE_NAME=${DATABASE_NAME:-"dawarich_development"} - -# Remove pre-existing puma/passenger server.pid -rm -f $APP_PATH/tmp/pids/server.pid - -# Wait for the database to be ready -until nc -zv $DATABASE_HOST ${DATABASE_PORT:-5432}; do - echo "Waiting for PostgreSQL to be ready..." - sleep 1 -done - -# Install gems -gem update --system 3.5.7 -gem install bundler --version '2.5.9' - -# Create the database -if [ "$(psql "postgres://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT" -XtAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'")" = '1' ]; then - echo "Database $DATABASE_NAME already exists, skipping creation..." -else - echo "Creating database $DATABASE_NAME..." - bundle exec rails db:create -fi - -# Run database migrations -echo "PostgreSQL is ready. Running database migrations..." -bundle exec rails db:prepare - -# Run data migrations -echo "Running DATA migrations..." -bundle exec rake data:migrate - -# Run seeds -echo "Running seeds..." -bundle exec rake db:seed - -# run passed commands -bundle exec ${@} diff --git a/dev-docker-sidekiq-entrypoint.sh b/dev-docker-sidekiq-entrypoint.sh deleted file mode 100644 index db7c87e9..00000000 --- a/dev-docker-sidekiq-entrypoint.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/sh - -set -e - -echo "Environment: $RAILS_ENV" - -# set env var defaults -DATABASE_HOST=${DATABASE_HOST:-"dawarich_db"} -DATABASE_PORT=${DATABASE_PORT:-5432} -DATABASE_USER=${DATABASE_USER:-"postgres"} -DATABASE_PASSWORD=${DATABASE_PASSWORD:-"password"} -DATABASE_NAME=${DATABASE_NAME:-"dawarich_development"} - -# Wait for the database to be ready -until nc -zv $DATABASE_HOST ${DATABASE_PORT:-5432}; do - echo "Waiting for PostgreSQL to be ready..." - sleep 1 -done - -# run passed commands -bundle exec ${@} diff --git a/.dockerignore b/docker/.dockerignore similarity index 100% rename from .dockerignore rename to docker/.dockerignore diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 00000000..37b04015 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,53 @@ +FROM ruby:3.3.4-alpine + +ENV APP_PATH=/var/app +ENV BUNDLE_VERSION=2.5.21 +ENV BUNDLE_PATH=/usr/local/bundle/gems +ENV RAILS_LOG_TO_STDOUT=true +ENV RAILS_PORT=3000 +ENV RAILS_ENV=development + +# Install dependencies for application +RUN apk -U add --no-cache \ + build-base \ + git \ + postgresql-dev \ + postgresql-client \ + libxml2-dev \ + libxslt-dev \ + nodejs \ + yarn \ + imagemagick \ + tzdata \ + less \ + yaml-dev \ + gcompat \ + && mkdir -p $APP_PATH + +# Update gem system and install bundler +RUN gem update --system 3.6.2 \ + && gem install bundler --version "$BUNDLE_VERSION" \ + && rm -rf $GEM_HOME/cache/* + +WORKDIR $APP_PATH + +COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ + +# Install all gems into the image +RUN bundle config set --local path 'vendor/bundle' \ + && bundle install --jobs 4 --retry 3 \ + && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem + +# Copy the rest of the application +COPY ../. ./ + +# Copy entrypoint scripts and grant execution permissions +COPY ./docker/web-entrypoint.sh /usr/local/bin/web-entrypoint.sh +RUN chmod +x /usr/local/bin/web-entrypoint.sh + +COPY ./docker/sidekiq-entrypoint.sh /usr/local/bin/sidekiq-entrypoint.sh +RUN chmod +x /usr/local/bin/sidekiq-entrypoint.sh + +EXPOSE $RAILS_PORT + +ENTRYPOINT [ "bundle", "exec" ] diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod new file mode 100644 index 00000000..8e801e98 --- /dev/null +++ b/docker/Dockerfile.prod @@ -0,0 +1,57 @@ +FROM ruby:3.3.4-alpine + +ENV APP_PATH=/var/app +ENV BUNDLE_VERSION=2.5.21 +ENV BUNDLE_PATH=/usr/local/bundle/gems +ENV RAILS_LOG_TO_STDOUT=true +ENV RAILS_PORT=3000 +ENV RAILS_ENV=production + +# Install dependencies for application +RUN apk -U add --no-cache \ + build-base \ + git \ + postgresql-dev \ + postgresql-client \ + libxml2-dev \ + libxslt-dev \ + nodejs \ + yarn \ + imagemagick \ + tzdata \ + less \ + yaml-dev \ + gcompat \ + && mkdir -p $APP_PATH + +# Update gem system and install bundler +RUN gem update --system 3.6.2 \ + && gem install bundler --version "$BUNDLE_VERSION" \ + && rm -rf $GEM_HOME/cache/* + +WORKDIR $APP_PATH + +COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ + +# Install production gems only +RUN bundle config set --local path 'vendor/bundle' \ + && bundle config set --local without 'development test' \ + && bundle install --jobs 4 --retry 3 \ + && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem + +COPY ../. ./ + +# Precompile assets for production +RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rake assets:precompile \ + && rm -rf node_modules tmp/cache + +# Copy entrypoint scripts and grant execution permissions +COPY ./docker/web-entrypoint.sh /usr/local/bin/web-entrypoint.sh +RUN chmod +x /usr/local/bin/web-entrypoint.sh + +COPY ./docker/sidekiq-entrypoint.sh /usr/local/bin/sidekiq-entrypoint.sh +RUN chmod +x /usr/local/bin/sidekiq-entrypoint.sh + +EXPOSE $RAILS_PORT + +ENTRYPOINT [ "bundle", "exec" ] diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml new file mode 100644 index 00000000..a4b83a34 --- /dev/null +++ b/docker/docker-compose.production.yml @@ -0,0 +1,156 @@ +networks: + dawarich: +services: + dawarich_redis: + image: redis:7.4-alpine + container_name: dawarich_redis + command: redis-server + networks: + - dawarich + volumes: + - dawarich_redis_data:/var/shared/redis + restart: always + healthcheck: + test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + dawarich_db: + image: postgres:17-alpine + shm_size: 1G + container_name: dawarich_db + volumes: + - dawarich_db_data:/var/lib/postgresql/data + networks: + - dawarich + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: dawarich_production + restart: always + healthcheck: + test: [ "CMD", "pg_isready", "-U", "postgres" ] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + dawarich_app: + image: dawarich:prod + container_name: dawarich_app + volumes: + - dawarich_public:/var/app/public + - dawarich_watched:/var/app/tmp/imports/watched + networks: + - dawarich + ports: + - 3000:3000 + # - 9394:9394 # Prometheus exporter, uncomment if needed + stdin_open: true + tty: true + entrypoint: web-entrypoint.sh + command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] + restart: on-failure + environment: + RAILS_ENV: production + REDIS_URL: redis://dawarich_redis:6379/0 + DATABASE_HOST: dawarich_db + DATABASE_PORT: 5432 + DATABASE_USERNAME: postgres + DATABASE_PASSWORD: password + DATABASE_NAME: dawarich_production + MIN_MINUTES_SPENT_IN_CITY: 60 + APPLICATION_HOSTS: localhost,::1,127.0.0.1 + TIME_ZONE: Europe/London + APPLICATION_PROTOCOL: http + DISTANCE_UNIT: km + PROMETHEUS_EXPORTER_ENABLED: false + PROMETHEUS_EXPORTER_HOST: 0.0.0.0 + PROMETHEUS_EXPORTER_PORT: 9394 + SECRET_KEY_BASE: 1234567890 + RAILS_LOG_TO_STDOUT: "true" + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" + healthcheck: + test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ] + interval: 10s + retries: 30 + start_period: 30s + timeout: 10s + depends_on: + dawarich_db: + condition: service_healthy + restart: true + dawarich_redis: + condition: service_healthy + restart: true + deploy: + resources: + limits: + cpus: '0.50' # Limit CPU usage to 50% of one core + memory: '4G' # Limit memory usage to 2GB + dawarich_sidekiq: + image: dawarich:prod + container_name: dawarich_sidekiq + volumes: + - dawarich_public:/var/app/public + - dawarich_watched:/var/app/tmp/imports/watched + networks: + - dawarich + stdin_open: true + tty: true + entrypoint: sidekiq-entrypoint.sh + command: ['bundle', 'exec', 'sidekiq'] + restart: on-failure + environment: + RAILS_ENV: production + REDIS_URL: redis://dawarich_redis:6379/0 + DATABASE_HOST: dawarich_db + DATABASE_PORT: 5432 + DATABASE_USERNAME: postgres + DATABASE_PASSWORD: password + DATABASE_NAME: dawarich_production + APPLICATION_HOSTS: localhost,::1,127.0.0.1 + BACKGROUND_PROCESSING_CONCURRENCY: 10 + APPLICATION_PROTOCOL: http + DISTANCE_UNIT: km + PROMETHEUS_EXPORTER_ENABLED: false + PROMETHEUS_EXPORTER_HOST: dawarich_app + PROMETHEUS_EXPORTER_PORT: 9394 + SECRET_KEY_BASE: 1234567890 + RAILS_LOG_TO_STDOUT: "true" + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" + healthcheck: + test: [ "CMD-SHELL", "bundle exec sidekiqmon processes | grep $${HOSTNAME}" ] + interval: 10s + retries: 30 + start_period: 30s + timeout: 10s + depends_on: + dawarich_db: + condition: service_healthy + restart: true + dawarich_redis: + condition: service_healthy + restart: true + dawarich_app: + condition: service_healthy + restart: true + deploy: + resources: + limits: + cpus: '0.50' # Limit CPU usage to 50% of one core + memory: '4G' # Limit memory usage to 2GB + +volumes: + dawarich_db_data: + dawarich_redis_data: + dawarich_public: + dawarich_watched: diff --git a/docker-compose.yml b/docker/docker-compose.yml similarity index 88% rename from docker-compose.yml rename to docker/docker-compose.yml index fc46ae30..93c0296f 100644 --- a/docker-compose.yml +++ b/docker/docker-compose.yml @@ -41,7 +41,6 @@ services: image: freikin/dawarich:latest container_name: dawarich_app volumes: - - dawarich_gem_cache_app:/usr/local/bundle/gems - dawarich_public:/var/app/public - dawarich_watched:/var/app/tmp/imports/watched networks: @@ -51,8 +50,8 @@ services: # - 9394:9394 # Prometheus exporter, uncomment if needed stdin_open: true tty: true - entrypoint: dev-entrypoint.sh - command: ['bin/dev'] + entrypoint: web-entrypoint.sh + command: ['bin/rails', 'server', '-p', '3000', '-b', '::'] restart: on-failure environment: RAILS_ENV: development @@ -62,13 +61,10 @@ services: DATABASE_PASSWORD: password DATABASE_NAME: dawarich_development MIN_MINUTES_SPENT_IN_CITY: 60 - APPLICATION_HOST: localhost APPLICATION_HOSTS: localhost TIME_ZONE: Europe/London APPLICATION_PROTOCOL: http DISTANCE_UNIT: km - PHOTON_API_HOST: photon.komoot.io - PHOTON_API_USE_HTTPS: true PROMETHEUS_EXPORTER_ENABLED: false PROMETHEUS_EXPORTER_HOST: 0.0.0.0 PROMETHEUS_EXPORTER_PORT: 9394 @@ -95,19 +91,18 @@ services: resources: limits: cpus: '0.50' # Limit CPU usage to 50% of one core - memory: '2G' # Limit memory usage to 2GB + memory: '4G' # Limit memory usage to 4GB dawarich_sidekiq: image: freikin/dawarich:latest container_name: dawarich_sidekiq volumes: - - dawarich_gem_cache_sidekiq:/usr/local/bundle/gems - dawarich_public:/var/app/public - dawarich_watched:/var/app/tmp/imports/watched networks: - dawarich stdin_open: true tty: true - entrypoint: dev-entrypoint.sh + entrypoint: sidekiq-entrypoint.sh command: ['sidekiq'] restart: on-failure environment: @@ -117,13 +112,10 @@ services: DATABASE_USERNAME: postgres DATABASE_PASSWORD: password DATABASE_NAME: dawarich_development - APPLICATION_HOST: localhost APPLICATION_HOSTS: localhost BACKGROUND_PROCESSING_CONCURRENCY: 10 APPLICATION_PROTOCOL: http DISTANCE_UNIT: km - PHOTON_API_HOST: photon.komoot.io - PHOTON_API_USE_HTTPS: true PROMETHEUS_EXPORTER_ENABLED: false PROMETHEUS_EXPORTER_HOST: dawarich_app PROMETHEUS_EXPORTER_PORT: 9394 @@ -153,12 +145,10 @@ services: resources: limits: cpus: '0.50' # Limit CPU usage to 50% of one core - memory: '2G' # Limit memory usage to 2GB + memory: '4G' # Limit memory usage to 4GB volumes: dawarich_db_data: - dawarich_gem_cache_app: - dawarich_gem_cache_sidekiq: dawarich_shared: dawarich_public: dawarich_watched: diff --git a/docker-compose_mounted_volumes.yml b/docker/docker-compose_mounted_volumes.yml similarity index 88% rename from docker-compose_mounted_volumes.yml rename to docker/docker-compose_mounted_volumes.yml index e724aa88..ef61f49a 100644 --- a/docker-compose_mounted_volumes.yml +++ b/docker/docker-compose_mounted_volumes.yml @@ -3,10 +3,6 @@ networks: volumes: - dawarich_gem_cache_app: - name: dawarich_gem_cache_app - dawarich_gem_cache_sidekiq: - name: dawarich_gem_cache_sidekiq dawarich_public: name: dawarich_public dawarich_keydb: @@ -41,18 +37,14 @@ services: DATABASE_PASSWORD: password DATABASE_NAME: dawarich_development MIN_MINUTES_SPENT_IN_CITY: 60 - APPLICATION_HOST: localhost APPLICATION_HOSTS: localhost APPLICATION_PROTOCOL: http DISTANCE_UNIT: km - PHOTON_API_HOST: photon.komoot.io - PHOTON_API_USE_HTTPS: true stdin_open: true tty: true entrypoint: dev-entrypoint.sh command: [ 'bin/dev' ] volumes: - - dawarich_gem_cache_app:/usr/local/bundle/gems - dawarich_public:/var/app/dawarich_public - watched:/var/app/tmp/imports/watched healthcheck: @@ -96,19 +88,15 @@ services: DATABASE_USERNAME: postgres DATABASE_PASSWORD: password DATABASE_NAME: dawarich_development - APPLICATION_HOST: localhost APPLICATION_HOSTS: localhost BACKGROUND_PROCESSING_CONCURRENCY: 10 APPLICATION_PROTOCOL: http DISTANCE_UNIT: km - PHOTON_API_HOST: photon.komoot.io - PHOTON_API_USE_HTTPS: true stdin_open: true tty: true entrypoint: dev-entrypoint.sh command: [ 'sidekiq' ] volumes: - - dawarich_gem_cache_sidekiq:/usr/local/bundle/gems - dawarich_public:/var/app/dawarich_public - watched:/var/app/tmp/imports/watched logging: @@ -158,7 +146,7 @@ services: - dawarich environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: eJH3YZsVc2s6byhFwpEny + POSTGRES_PASSWORD: password POSTGRES_DATABASE: dawarich volumes: - ./db:/var/lib/postgresql/data diff --git a/docker/sidekiq-entrypoint.sh b/docker/sidekiq-entrypoint.sh new file mode 100644 index 00000000..1083891b --- /dev/null +++ b/docker/sidekiq-entrypoint.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +unset BUNDLE_PATH +unset BUNDLE_BIN + +set -e + +echo "⚠️ Starting Sidekiq in $RAILS_ENV environment ⚠️" + +# Parse DATABASE_URL if present, otherwise use individual variables +if [ -n "$DATABASE_URL" ]; then + # Extract components from DATABASE_URL + DATABASE_HOST=$(echo $DATABASE_URL | awk -F[@/] '{print $4}') + DATABASE_PORT=$(echo $DATABASE_URL | awk -F[@/:] '{print $5}') + DATABASE_USERNAME=$(echo $DATABASE_URL | awk -F[:/@] '{print $4}') + DATABASE_PASSWORD=$(echo $DATABASE_URL | awk -F[:/@] '{print $5}') +else + # Use existing environment variables + DATABASE_HOST=${DATABASE_HOST} + DATABASE_PORT=${DATABASE_PORT} + DATABASE_USERNAME=${DATABASE_USERNAME} + DATABASE_PASSWORD=${DATABASE_PASSWORD} +fi + +# Wait for the database to become available +echo "⏳ Waiting for database to be ready..." +until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do + >&2 echo "Postgres is unavailable - retrying..." + sleep 2 +done +echo "✅ PostgreSQL is ready!" + +# run sidekiq +bundle exec sidekiq diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh new file mode 100644 index 00000000..230f91cc --- /dev/null +++ b/docker/web-entrypoint.sh @@ -0,0 +1,58 @@ +#!/bin/sh + +unset BUNDLE_PATH +unset BUNDLE_BIN + +set -e + +echo "⚠️ Starting Rails environment: $RAILS_ENV ⚠️" + +# Parse DATABASE_URL if present, otherwise use individual variables +if [ -n "$DATABASE_URL" ]; then + # Extract components from DATABASE_URL + DATABASE_HOST=$(echo $DATABASE_URL | awk -F[@/] '{print $4}') + DATABASE_PORT=$(echo $DATABASE_URL | awk -F[@/:] '{print $5}') + DATABASE_USERNAME=$(echo $DATABASE_URL | awk -F[:/@] '{print $4}') + DATABASE_PASSWORD=$(echo $DATABASE_URL | awk -F[:/@] '{print $5}') + DATABASE_NAME=$(echo $DATABASE_URL | awk -F[@/] '{print $5}') +else + # Use existing environment variables + DATABASE_HOST=${DATABASE_HOST} + DATABASE_PORT=${DATABASE_PORT} + DATABASE_USERNAME=${DATABASE_USERNAME} + DATABASE_PASSWORD=${DATABASE_PASSWORD} + DATABASE_NAME=${DATABASE_NAME} +fi + +# Remove pre-existing puma/passenger server.pid +rm -f $APP_PATH/tmp/pids/server.pid + +# Wait for the database to become available +echo "⏳ Waiting for database to be ready..." +until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do + >&2 echo "Postgres is unavailable - retrying..." + sleep 2 +done +echo "✅ PostgreSQL is ready!" + +# Create database if it doesn't exist +if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then + echo "Creating database $DATABASE_NAME..." + bundle exec rails db:create +fi + +# Run database migrations +echo "PostgreSQL is ready. Running database migrations..." +bundle exec rails db:migrate + +# Run data migrations +echo "Running DATA migrations..." +bundle exec rake data:migrate + +# if [ "$RAILS_ENV" != "production" ]; then + echo "Running seeds..." + bundle exec rails db:seed +# fi + +# run passed commands +bundle exec ${@} diff --git a/docs/How_to_install_Dawarich_on_Synology.md b/docs/How_to_install_Dawarich_on_Synology.md index 228d5137..ff17f1a8 100644 --- a/docs/How_to_install_Dawarich_on_Synology.md +++ b/docs/How_to_install_Dawarich_on_Synology.md @@ -29,7 +29,7 @@ If you don't want to use dedicated share for projects installed by docker skip i ### Dawarich root folder 1. Open your [Docker root folder](#docker-root-share) in **File station**. 2. Create new folder **dawarich** and open it. -3. Create folders **redis**, **db_data**, **db_shared**, **gem_cache** and **public** in **dawarich** folder. +3. Create folders **redis**, **db_data**, **db_shared** and **public** in **dawarich** folder. 4. Copy [docker compose](synology/docker-compose.yml) and [.env](synology/.env) files form **synology** repo folder into **dawarich** folder on your synology. # Installation diff --git a/docs/how_to_setup_reverse_proxy.md b/docs/how_to_setup_reverse_proxy.md index 30cb691b..efaddd2d 100644 --- a/docs/how_to_setup_reverse_proxy.md +++ b/docs/how_to_setup_reverse_proxy.md @@ -14,7 +14,6 @@ dawarich_app: ... environment: ... - APPLICATION_HOST: "yourhost.com" <------------------------------ Edit this APPLICATION_HOSTS: "yourhost.com,www.yourhost.com,127.0.0.1" <-- Edit this ``` @@ -25,7 +24,6 @@ dawarich_sidekiq: ... environment: ... - APPLICATION_HOST: "yourhost.com" <------------------------------ Edit this APPLICATION_HOSTS: "yourhost.com,www.yourhost.com,127.0.0.1" <-- Edit this ... ``` @@ -48,7 +46,7 @@ server { brotli on; brotli_comp_level 6; - brotli_types + brotli_types text/css text/plain text/xml @@ -106,24 +104,24 @@ With the above commands entered, the configuration below should work properly. ```apache ServerName example.com - + ProxyRequests Off ProxyPreserveHost On - + Require all granted - + Header always set X-Real-IP %{REMOTE_ADDR}s Header always set X-Forwarded-For %{REMOTE_ADDR}s Header always set X-Forwarded-Proto https Header always set X-Forwarded-Server %{SERVER_NAME}s Header always set Host %{HTTP_HOST}s - + SetOutputFilter BROTLI AddOutputFilterByType BROTLI_COMPRESS text/css text/plain text/xml text/javascript application/javascript application/json application/manifest+json application/vnd.api+json application/xml application/xhtml+xml application/rss+xml application/atom+xml application/vnd.ms-fontobject application/x-font-ttf application/x-font-opentype application/x-font-truetype image/svg+xml image/x-icon image/vnd.microsoft.icon font/ttf font/eot font/otf font/opentype BrotliCompressionQuality 6 - + ProxyPass / http://127.0.0.1:3000/ ProxyPassReverse / http://127.0.0.1:3000/ diff --git a/docs/synology/.env b/docs/synology/.env index 48273f26..cfc5f19b 100644 --- a/docs/synology/.env +++ b/docs/synology/.env @@ -4,10 +4,9 @@ RAILS_ENV=development MIN_MINUTES_SPENT_IN_CITY=60 -APPLICATION_HOST=dawarich.djhrum.synology.me +APPLICATION_HOSTS=dawarich.example.synology.me TIME_ZONE=Europe/Berlin BACKGROUND_PROCESSING_CONCURRENCY=10 -MAP_CENTER=[52.520826, 13.409690] ################################################################################### # Database diff --git a/docs/synology/docker-compose.yml b/docs/synology/docker-compose.yml index 6a9f14ed..62092437 100644 --- a/docs/synology/docker-compose.yml +++ b/docs/synology/docker-compose.yml @@ -8,7 +8,7 @@ services: restart: unless-stopped volumes: - ./redis:/var/shared/redis - + dawarich_db: image: postgres:14.2-alpine container_name: dawarich_db @@ -28,17 +28,16 @@ services: - dawarich_redis stdin_open: true tty: true - entrypoint: dev-entrypoint.sh + entrypoint: web-entrypoint.sh command: ['bin/dev'] restart: unless-stopped env_file: - .env volumes: - - ./gem_cache:/usr/local/bundle/gems - ./public:/var/app/public ports: - 32568:3000 - + dawarich_sidekiq: image: freikin/dawarich:latest container_name: dawarich_sidekiq @@ -46,11 +45,10 @@ services: - dawarich_db - dawarich_redis - dawarich_app - entrypoint: dev-entrypoint.sh + entrypoint: sidekiq-entrypoint.sh command: ['sidekiq'] restart: unless-stopped env_file: - .env volumes: - - ./gem_cache:/usr/local/bundle/gems - ./public:/var/app/public diff --git a/spec/factories/points.rb b/spec/factories/points.rb index 6ae12ab2..2288a07d 100644 --- a/spec/factories/points.rb +++ b/spec/factories/points.rb @@ -25,6 +25,10 @@ FactoryBot.define do import_id { '' } city { nil } country { nil } + reverse_geocoded_at { nil } + course { nil } + course_accuracy { nil } + external_track_id { nil } user trait :with_known_location do diff --git a/spec/factories/trips.rb b/spec/factories/trips.rb index 237a187b..4ef4041a 100644 --- a/spec/factories/trips.rb +++ b/spec/factories/trips.rb @@ -10,11 +10,15 @@ FactoryBot.define do trait :with_points do after(:build) do |trip| - create_list( - :point, 25, - user: trip.user, - timestamp: trip.started_at + (1..1000).to_a.sample.minutes - ) + (1..25).map do |i| + create( + :point, + :with_geodata, + :reverse_geocoded, + timestamp: trip.started_at + i.minutes, + user: trip.user + ) + end end end end diff --git a/spec/fixtures/files/geojson/export_same_points.json b/spec/fixtures/files/geojson/export_same_points.json index 7a20a47f..f3961b32 100644 --- a/spec/fixtures/files/geojson/export_same_points.json +++ b/spec/fixtures/files/geojson/export_same_points.json @@ -1 +1 @@ -{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00"}}]} +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459201,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459202,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459203,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459204,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459205,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459206,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459207,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459208,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":["37.6173","55.755826"]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459209,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"reverse_geocoded_at":"2021-01-01T00:00:00.000+01:00","course":null,"course_accuracy":null,"external_track_id":null}}]} diff --git a/spec/fixtures/files/gpx/arc_example.gpx b/spec/fixtures/files/gpx/arc_example.gpx new file mode 100644 index 00000000..f944f776 --- /dev/null +++ b/spec/fixtures/files/gpx/arc_example.gpx @@ -0,0 +1,41 @@ + + + + + 89.9031832732575 + Topland Hotel & Convention Center + + + walking + + + + taxi + + + 49.96302288016834 + + + + 49.884678590538186 + + + + 49.71960135141746 + + + + 49.91594081568717 + + + + 50.344669848377556 + + + + 50.12800953488726 + + + + + diff --git a/spec/fixtures/files/points/geojson_example.json b/spec/fixtures/files/points/geojson_example.json new file mode 100644 index 00000000..c1cac9e4 --- /dev/null +++ b/spec/fixtures/files/points/geojson_example.json @@ -0,0 +1,136 @@ +{ + "locations" : [ + { + "type" : "Feature", + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.40530871, + 37.744304130000003 + ] + }, + "properties" : { + "horizontal_accuracy" : 5, + "track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F", + "speed_accuracy" : 0, + "vertical_accuracy" : -1, + "course_accuracy" : 0, + "altitude" : 0, + "speed" : 92.087999999999994, + "course" : 27.07, + "timestamp" : "2025-01-17T21:03:01Z", + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46" + } + }, + { + "type" : "Feature", + "properties" : { + "timestamp" : "2025-01-17T21:03:02Z", + "horizontal_accuracy" : 5, + "course" : 24.260000000000002, + "speed_accuracy" : 0, + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "vertical_accuracy" : -1, + "altitude" : 0, + "track_id" : "799F32F5-89BB-45FB-A639-098B1B95B09F", + "speed" : 92.448000000000008, + "course_accuracy" : 0 + }, + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.40518926999999, + 37.744513759999997 + ] + } + }, + { + "type" : "Feature", + "properties" : { + "altitude" : 0, + "horizontal_accuracy" : 5, + "speed" : 123.76800000000001, + "course_accuracy" : 0, + "speed_accuracy" : 0, + "course" : 309.73000000000002, + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "timestamp" : "2025-01-17T21:18:38Z", + "vertical_accuracy" : -1 + }, + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.28487643, + 37.454486080000002 + ] + } + }, + { + "type" : "Feature", + "properties" : { + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "speed_accuracy" : 0, + "course_accuracy" : 0, + "speed" : 123.3, + "horizontal_accuracy" : 5, + "course" : 309.38, + "altitude" : 0, + "timestamp" : "2025-01-17T21:18:39Z", + "vertical_accuracy" : -1 + }, + "geometry" : { + "coordinates" : [ + -122.28517332, + 37.454684899999997 + ], + "type" : "Point" + } + }, + { + "geometry" : { + "coordinates" : [ + -122.28547306, + 37.454883219999999 + ], + "type" : "Point" + }, + "properties" : { + "course_accuracy" : 0, + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "vertical_accuracy" : -1, + "course" : 309.73000000000002, + "speed_accuracy" : 0, + "timestamp" : "2025-01-17T21:18:40Z", + "horizontal_accuracy" : 5, + "speed" : 125.06400000000001, + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "altitude" : 0 + }, + "type" : "Feature" + }, + { + "geometry" : { + "type" : "Point", + "coordinates" : [ + -122.28577665, + 37.455080109999997 + ] + }, + "properties" : { + "course_accuracy" : 0, + "speed_accuracy" : 0, + "speed" : 124.05600000000001, + "track_id" : "F63A3CF9-2FF8-4076-8F59-5BB1EDC23888", + "course" : 309.73000000000002, + "device_id" : "8D5D4197-245B-4619-A88B-2049100ADE46", + "altitude" : 0, + "horizontal_accuracy" : 5, + "vertical_accuracy" : -1, + "timestamp" : "2025-01-17T21:18:41Z" + }, + "type" : "Feature" + } + ] +} diff --git a/spec/jobs/bulk_stats_calculating_job_spec.rb b/spec/jobs/bulk_stats_calculating_job_spec.rb index 15bbc9fb..632fa47e 100644 --- a/spec/jobs/bulk_stats_calculating_job_spec.rb +++ b/spec/jobs/bulk_stats_calculating_job_spec.rb @@ -9,8 +9,17 @@ RSpec.describe BulkStatsCalculatingJob, type: :job do let(:timestamp) { DateTime.new(2024, 1, 1).to_i } - let!(:points1) { create_list(:point, 10, user_id: user1.id, timestamp:) } - let!(:points2) { create_list(:point, 10, user_id: user2.id, timestamp:) } + let!(:points1) do + (1..10).map do |i| + create(:point, user_id: user1.id, timestamp: timestamp + i.minutes) + end + end + + let!(:points2) do + (1..10).map do |i| + create(:point, user_id: user2.id, timestamp: timestamp + i.minutes) + end + end it 'enqueues Stats::CalculatingJob for each user' do expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1) diff --git a/spec/jobs/points/create_job_spec.rb b/spec/jobs/points/create_job_spec.rb new file mode 100644 index 00000000..7fa14b15 --- /dev/null +++ b/spec/jobs/points/create_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::CreateJob, type: :job do + describe '#perform' do + subject(:perform) { described_class.new.perform(json, user.id) } + + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:user) { create(:user) } + + it 'creates a point' do + expect { perform }.to change { Point.count }.by(6) + end + end +end diff --git a/spec/jobs/reverse_geocoding_job_spec.rb b/spec/jobs/reverse_geocoding_job_spec.rb index dfd3da8e..b6420be0 100644 --- a/spec/jobs/reverse_geocoding_job_spec.rb +++ b/spec/jobs/reverse_geocoding_job_spec.rb @@ -12,8 +12,8 @@ RSpec.describe ReverseGeocodingJob, type: :job do allow(Geocoder).to receive(:search).and_return([double(city: 'City', country: 'Country')]) end - context 'when REVERSE_GEOCODING_ENABLED is false' do - before { stub_const('REVERSE_GEOCODING_ENABLED', false) } + context 'when reverse geocoding is disabled' do + before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false) } it 'does not update point' do expect { perform }.not_to(change { point.reload.city }) @@ -28,8 +28,8 @@ RSpec.describe ReverseGeocodingJob, type: :job do end end - context 'when REVERSE_GEOCODING_ENABLED is true' do - before { stub_const('REVERSE_GEOCODING_ENABLED', true) } + context 'when reverse geocoding is enabled' do + before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) } let(:stubbed_geocoder) { OpenStruct.new(data: { city: 'City', country: 'Country' }) } diff --git a/spec/jobs/visit_suggesting_job_spec.rb b/spec/jobs/visit_suggesting_job_spec.rb index 70af4e74..f2ce47d9 100644 --- a/spec/jobs/visit_suggesting_job_spec.rb +++ b/spec/jobs/visit_suggesting_job_spec.rb @@ -3,9 +3,9 @@ require 'rails_helper' RSpec.describe VisitSuggestingJob, type: :job do - describe '#perform' do - let!(:users) { [create(:user)] } + let!(:users) { [create(:user)] } + describe '#perform' do subject { described_class.perform_now } before do @@ -13,10 +13,22 @@ RSpec.describe VisitSuggestingJob, type: :job do allow_any_instance_of(Visits::Suggest).to receive(:call) end - it 'suggests visits' do - subject + context 'when user has no tracked points' do + it 'does not suggest visits' do + subject - expect(Visits::Suggest).to have_received(:new) + expect(Visits::Suggest).not_to have_received(:new) + end + end + + context 'when user has tracked points' do + let!(:tracked_point) { create(:point, user: users.first) } + + it 'suggests visits' do + subject + + expect(Visits::Suggest).to have_received(:new) + end end end end diff --git a/spec/lib/dawarich_settings_spec.rb b/spec/lib/dawarich_settings_spec.rb new file mode 100644 index 00000000..bce61846 --- /dev/null +++ b/spec/lib/dawarich_settings_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe DawarichSettings do + before do + described_class.instance_variables.each do |ivar| + described_class.remove_instance_variable(ivar) + end + end + + describe '.reverse_geocoding_enabled?' do + context 'when photon is enabled' do + before do + allow(described_class).to receive(:photon_enabled?).and_return(true) + allow(described_class).to receive(:geoapify_enabled?).and_return(false) + end + + it 'returns true' do + expect(described_class.reverse_geocoding_enabled?).to be true + end + end + + context 'when geoapify is enabled' do + before do + allow(described_class).to receive(:photon_enabled?).and_return(false) + allow(described_class).to receive(:geoapify_enabled?).and_return(true) + end + + it 'returns true' do + expect(described_class.reverse_geocoding_enabled?).to be true + end + end + + context 'when neither service is enabled' do + before do + allow(described_class).to receive(:photon_enabled?).and_return(false) + allow(described_class).to receive(:geoapify_enabled?).and_return(false) + end + + it 'returns false' do + expect(described_class.reverse_geocoding_enabled?).to be false + end + end + end + + describe '.photon_enabled?' do + context 'when PHOTON_API_HOST is present' do + before { stub_const('PHOTON_API_HOST', 'photon.example.com') } + + it 'returns true' do + expect(described_class.photon_enabled?).to be true + end + end + + context 'when PHOTON_API_HOST is blank' do + before { stub_const('PHOTON_API_HOST', '') } + + it 'returns false' do + expect(described_class.photon_enabled?).to be false + end + end + end + + describe '.photon_uses_komoot_io?' do + context 'when PHOTON_API_HOST is komoot.io' do + before { stub_const('PHOTON_API_HOST', 'photon.komoot.io') } + + it 'returns true' do + expect(described_class.photon_uses_komoot_io?).to be true + end + end + + context 'when PHOTON_API_HOST is different' do + before { stub_const('PHOTON_API_HOST', 'photon.example.com') } + + it 'returns false' do + expect(described_class.photon_uses_komoot_io?).to be false + end + end + end + + describe '.geoapify_enabled?' do + context 'when GEOAPIFY_API_KEY is present' do + before { stub_const('GEOAPIFY_API_KEY', 'some-api-key') } + + it 'returns true' do + expect(described_class.geoapify_enabled?).to be true + end + end + + context 'when GEOAPIFY_API_KEY is blank' do + before { stub_const('GEOAPIFY_API_KEY', '') } + + it 'returns false' do + expect(described_class.geoapify_enabled?).to be false + end + end + end +end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index d6c7efc8..8b682409 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -26,7 +26,11 @@ RSpec.describe Import, type: :model do describe '#years_and_months_tracked' do let(:import) { create(:import) } let(:timestamp) { Time.zone.local(2024, 11, 1) } - let!(:points) { create_list(:point, 3, import:, timestamp:) } + let!(:points) do + (1..3).map do |i| + create(:point, import:, timestamp: timestamp + i.minutes) + end + end it 'returns years and months tracked' do expect(import.years_and_months_tracked).to eq([[2024, 11]]) diff --git a/spec/models/place_spec.rb b/spec/models/place_spec.rb index 3a7bcd21..48722d9c 100644 --- a/spec/models/place_spec.rb +++ b/spec/models/place_spec.rb @@ -23,6 +23,8 @@ RSpec.describe Place, type: :model do describe '#async_reverse_geocode' do let(:place) { create(:place) } + before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) } + it 'updates address' do expect { place.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob).with('Place', place.id) end diff --git a/spec/models/point_spec.rb b/spec/models/point_spec.rb index b2e98bf2..c1972838 100644 --- a/spec/models/point_spec.rb +++ b/spec/models/point_spec.rb @@ -46,6 +46,8 @@ RSpec.describe Point, type: :model do describe '#async_reverse_geocode' do let(:point) { build(:point) } + before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) } + it 'enqueues ReverseGeocodeJob with correct arguments' do point.save @@ -56,14 +58,8 @@ RSpec.describe Point, type: :model do context 'when point is imported' do let(:point) { build(:point, import_id: 1) } - it 'does not enqueue ReverseGeocodeJob' do - expect { point.async_reverse_geocode }.not_to have_enqueued_job(ReverseGeocodingJob) - end - - context 'when reverse geocoding is forced' do - it 'enqueues ReverseGeocodeJob' do - expect { point.async_reverse_geocode(force: true) }.to have_enqueued_job(ReverseGeocodingJob) - end + it 'enqueues ReverseGeocodeJob' do + expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob) end end end diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb index af8873b6..1208e006 100644 --- a/spec/models/stat_spec.rb +++ b/spec/models/stat_spec.rb @@ -89,8 +89,14 @@ RSpec.describe Stat, type: :model do subject { stat.points.to_a } let(:stat) { create(:stat, year:, month: 1, user:) } - let(:timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) } - let!(:points) { create_list(:point, 3, user:, timestamp:) } + let(:base_timestamp) { DateTime.new(year, 1, 1, 5, 0, 0) } + let!(:points) do + [ + create(:point, user:, timestamp: base_timestamp), + create(:point, user:, timestamp: base_timestamp + 1.hour), + create(:point, user:, timestamp: base_timestamp + 2.hours) + ] + end it 'returns points' do expect(subject).to eq(points) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index a1059d0a..398e436f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -115,7 +115,11 @@ RSpec.describe User, type: :model do end describe '#years_tracked' do - let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) } + let!(:points) do + (1..3).map do |i| + create(:point, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0) + i.minutes) + end + end it 'returns years tracked' do expect(user.years_tracked).to eq([{ year: 2024, months: ['Jan'] }]) diff --git a/spec/requests/api/v1/health_spec.rb b/spec/requests/api/v1/health_spec.rb index 264dbdcb..4861b399 100644 --- a/spec/requests/api/v1/health_spec.rb +++ b/spec/requests/api/v1/health_spec.rb @@ -9,6 +9,18 @@ RSpec.describe 'Api::V1::Healths', type: :request do get '/api/v1/health' expect(response).to have_http_status(:success) + expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive!') + end + end + + context 'when user is authenticated' do + let(:user) { create(:user) } + + it 'returns http success' do + get '/api/v1/health', headers: { 'Authorization' => "Bearer #{user.api_key}" } + + expect(response).to have_http_status(:success) + expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive and authenticated!') end end end diff --git a/spec/requests/api/v1/points_spec.rb b/spec/requests/api/v1/points_spec.rb index 5120e5ce..3d5f49d8 100644 --- a/spec/requests/api/v1/points_spec.rb +++ b/spec/requests/api/v1/points_spec.rb @@ -4,7 +4,11 @@ require 'rails_helper' RSpec.describe 'Api::V1::Points', type: :request do let!(:user) { create(:user) } - let!(:points) { create_list(:point, 150, user:) } + let!(:points) do + (1..15).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end describe 'GET /index' do context 'when regular version of points is requested' do @@ -21,7 +25,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(100) + expect(json_response.size).to eq(15) end it 'returns a list of points with pagination' do @@ -31,7 +35,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(10) + expect(json_response.size).to eq(5) end it 'returns a list of points with pagination headers' do @@ -40,7 +44,7 @@ RSpec.describe 'Api::V1::Points', type: :request do expect(response).to have_http_status(:ok) expect(response.headers['X-Current-Page']).to eq('2') - expect(response.headers['X-Total-Pages']).to eq('15') + expect(response.headers['X-Total-Pages']).to eq('2') end end @@ -58,7 +62,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(100) + expect(json_response.size).to eq(15) end it 'returns a list of points with pagination' do @@ -68,7 +72,7 @@ RSpec.describe 'Api::V1::Points', type: :request do json_response = JSON.parse(response.body) - expect(json_response.size).to eq(10) + expect(json_response.size).to eq(5) end it 'returns a list of points with pagination headers' do @@ -77,7 +81,7 @@ RSpec.describe 'Api::V1::Points', type: :request do expect(response).to have_http_status(:ok) expect(response.headers['X-Current-Page']).to eq('2') - expect(response.headers['X-Total-Pages']).to eq('15') + expect(response.headers['X-Total-Pages']).to eq('2') end it 'returns a list of points with slim attributes' do diff --git a/spec/requests/api/v1/stats_spec.rb b/spec/requests/api/v1/stats_spec.rb index d733ae3f..89cdc8e4 100644 --- a/spec/requests/api/v1/stats_spec.rb +++ b/spec/requests/api/v1/stats_spec.rb @@ -10,14 +10,20 @@ RSpec.describe 'Api::V1::Stats', type: :request do let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } let!(:points_in_2020) do - create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:) + (1..85).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:) + end + end + let!(:points_in_2021) do + (1..95).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:) + end end - let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) } let(:expected_json) do { totalDistanceKm: stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum, totalPointsTracked: points_in_2020.count + points_in_2021.count, - totalReverseGeocodedPoints: points_in_2020.count, + totalReverseGeocodedPoints: points_in_2020.count + points_in_2021.count, totalCountriesVisited: 1, totalCitiesVisited: 1, yearlyStats: [ diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb new file mode 100644 index 00000000..3075a94f --- /dev/null +++ b/spec/requests/api/v1/users_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Users', type: :request do + describe 'GET /me' do + let(:user) { create(:user) } + let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } + + it 'returns http success' do + get '/api/v1/users/me', headers: headers + + expect(response).to have_http_status(:success) + expect(response.body).to include(user.email) + expect(response.body).to include(user.id.to_s) + end + end +end diff --git a/spec/requests/exports_spec.rb b/spec/requests/exports_spec.rb index c96ac744..0ec6fa61 100644 --- a/spec/requests/exports_spec.rb +++ b/spec/requests/exports_spec.rb @@ -37,7 +37,11 @@ RSpec.describe '/exports', type: :request do before { sign_in user } context 'with valid parameters' do - let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) } + let(:points) do + (1..10).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end it 'creates a new Export' do expect { post exports_url, params: }.to change(Export, :count).by(1) diff --git a/spec/requests/map_spec.rb b/spec/requests/map_spec.rb index 3cda64a5..700a214a 100644 --- a/spec/requests/map_spec.rb +++ b/spec/requests/map_spec.rb @@ -11,7 +11,11 @@ RSpec.describe 'Map', type: :request do describe 'GET /index' do context 'when user signed in' do let(:user) { create(:user) } - let(:points) { create_list(:point, 10, user:, timestamp: 1.day.ago) } + let(:points) do + (1..10).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end before { sign_in user } diff --git a/spec/serializers/export_serializer_spec.rb b/spec/serializers/export_serializer_spec.rb index e77acff5..353d53fb 100644 --- a/spec/serializers/export_serializer_spec.rb +++ b/spec/serializers/export_serializer_spec.rb @@ -7,7 +7,12 @@ RSpec.describe ExportSerializer do subject(:serializer) { described_class.new(points, user_email).call } let(:user_email) { 'ab@cd.com' } - let(:points) { create_list(:point, 2) } + let(:points) do + (1..2).map do |i| + create(:point, timestamp: 1.day.ago + i.minutes) + end + end + let(:expected_json) do { user_email => { diff --git a/spec/serializers/points/geojson_serializer_spec.rb b/spec/serializers/points/geojson_serializer_spec.rb index a532a192..e125c7b3 100644 --- a/spec/serializers/points/geojson_serializer_spec.rb +++ b/spec/serializers/points/geojson_serializer_spec.rb @@ -6,7 +6,12 @@ RSpec.describe Points::GeojsonSerializer do describe '#call' do subject(:serializer) { described_class.new(points).call } - let(:points) { create_list(:point, 3) } + let(:points) do + (1..3).map do |i| + create(:point, timestamp: 1.day.ago + i.minutes) + end + end + let(:expected_json) do { type: 'FeatureCollection', diff --git a/spec/serializers/points/gpx_serializer_spec.rb b/spec/serializers/points/gpx_serializer_spec.rb index e2b108b9..1434ca5d 100644 --- a/spec/serializers/points/gpx_serializer_spec.rb +++ b/spec/serializers/points/gpx_serializer_spec.rb @@ -6,7 +6,11 @@ RSpec.describe Points::GpxSerializer do describe '#call' do subject(:serializer) { described_class.new(points, 'some_name').call } - let(:points) { create_list(:point, 3) } + let(:points) do + (1..3).map do |i| + create(:point, timestamp: 1.day.ago + i.minutes) + end + end it 'returns GPX file' do expect(serializer).to be_a(GPX::GPXFile) diff --git a/spec/serializers/stats_serializer_spec.rb b/spec/serializers/stats_serializer_spec.rb index ad6f5bc8..2fba6656 100644 --- a/spec/serializers/stats_serializer_spec.rb +++ b/spec/serializers/stats_serializer_spec.rb @@ -29,16 +29,20 @@ RSpec.describe StatsSerializer do let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } let!(:points_in_2020) do - create_list(:point, 85, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020), user:) + (1..85).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, user:) + end end let!(:points_in_2021) do - create_list(:point, 95, timestamp: Time.zone.local(2021), user:) + (1..95).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, user:) + end end let(:expected_json) do { "totalDistanceKm": stats_in_2020.map(&:distance).sum + stats_in_2021.map(&:distance).sum, "totalPointsTracked": points_in_2020.count + points_in_2021.count, - "totalReverseGeocodedPoints": points_in_2020.count, + "totalReverseGeocodedPoints": points_in_2020.count + points_in_2021.count, "totalCountriesVisited": 1, "totalCitiesVisited": 1, "yearlyStats": [ diff --git a/spec/services/check_app_version_spec.rb b/spec/services/check_app_version_spec.rb index b58cc2e5..1e90b3af 100644 --- a/spec/services/check_app_version_spec.rb +++ b/spec/services/check_app_version_spec.rb @@ -29,6 +29,15 @@ RSpec.describe CheckAppVersion do it { is_expected.to be true } end + context 'when latest version is not a stable release' do + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0-rc.1"}]', headers: {}) + end + + it { is_expected.to be false } + end + context 'when request fails' do before do allow(Net::HTTP).to receive(:get).and_raise(StandardError) diff --git a/spec/services/exports/create_spec.rb b/spec/services/exports/create_spec.rb index 2110b6b0..1bea40d2 100644 --- a/spec/services/exports/create_spec.rb +++ b/spec/services/exports/create_spec.rb @@ -15,7 +15,12 @@ RSpec.describe Exports::Create do let(:export_content) { Points::GeojsonSerializer.new(points).call } let(:reverse_geocoded_at) { Time.zone.local(2021, 1, 1) } let!(:points) do - create_list(:point, 10, :with_known_location, user:, timestamp: start_at.to_datetime.to_i, reverse_geocoded_at:) + 10.times.map do |i| + create(:point, :with_known_location, + user: user, + timestamp: start_at.to_datetime.to_i + i, + reverse_geocoded_at: reverse_geocoded_at) + end end before do diff --git a/spec/services/google_maps/records_importer_spec.rb b/spec/services/google_maps/records_importer_spec.rb new file mode 100644 index 00000000..8ce4d69d --- /dev/null +++ b/spec/services/google_maps/records_importer_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe GoogleMaps::RecordsImporter do + describe '#call' do + subject(:parser) { described_class.new(import).call(locations) } + + let(:import) { create(:import) } + let(:time) { DateTime.new(2025, 1, 1, 12, 0, 0) } + let(:locations) do + [ + { + 'timestampMs' => (time.to_f * 1000).to_i.to_s, + 'latitudeE7' => 123_456_789, + 'longitudeE7' => 123_456_789, + 'accuracy' => 10, + 'altitude' => 100, + 'verticalAccuracy' => 5, + 'activity' => [ + { + 'timestampMs' => (time.to_f * 1000).to_i.to_s, + 'activity' => [ + { + 'type' => 'STILL', + 'confidence' => 100 + } + ] + } + ] + } + ] + end + + context 'with regular timestamp' do + let(:locations) { super()[0].merge('timestamp' => time.to_s).to_json } + + it 'creates a point' do + expect { parser }.to change(Point, :count).by(1) + end + end + + context 'when point already exists' do + let(:locations) do + [ + super()[0].merge( + 'timestamp' => time.to_s, + 'latitudeE7' => 123_456_789, + 'longitudeE7' => 123_456_789 + ) + ] + end + + before do + create( + :point, + user: import.user, + import: import, + latitude: 12.3456789, + longitude: 12.3456789, + timestamp: time.to_i + ) + end + + it 'does not create a point' do + expect { parser }.not_to change(Point, :count) + end + end + + context 'with timestampMs in milliseconds' do + let(:locations) do + [super()[0].merge('timestampMs' => (time.to_f * 1000).to_i.to_s)] + end + + it 'creates a point using milliseconds timestamp' do + expect { parser }.to change(Point, :count).by(1) + end + end + + context 'with ISO 8601 timestamp' do + let(:locations) do + [super()[0].merge('timestamp' => time.iso8601)] + end + + it 'parses ISO 8601 timestamp correctly' do + expect { parser }.to change(Point, :count).by(1) + created_point = Point.last + expect(created_point.timestamp).to eq(time.to_i) + end + end + + context 'with timestamp in milliseconds' do + let(:locations) do + [super()[0].merge('timestamp' => (time.to_f * 1000).to_i.to_s)] + end + + it 'parses millisecond timestamp correctly' do + expect { parser }.to change(Point, :count).by(1) + created_point = Point.last + expect(created_point.timestamp).to eq(time.to_i) + end + end + + context 'with timestamp in seconds' do + let(:locations) do + [super()[0].merge('timestamp' => time.to_i.to_s)] + end + + it 'parses second timestamp correctly' do + expect { parser }.to change(Point, :count).by(1) + created_point = Point.last + expect(created_point.timestamp).to eq(time.to_i) + end + end + end +end diff --git a/spec/services/google_maps/records_parser_spec.rb b/spec/services/google_maps/records_parser_spec.rb deleted file mode 100644 index 44ec23b6..00000000 --- a/spec/services/google_maps/records_parser_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe GoogleMaps::RecordsParser do - describe '#call' do - subject(:parser) { described_class.new(import).call(json) } - - let(:import) { create(:import) } - let(:time) { Time.zone.now } - let(:json) do - { - 'latitudeE7' => 123_456_789, - 'longitudeE7' => 123_456_789, - 'altitude' => 0, - 'velocity' => 0 - } - end - - context 'with regular timestamp' do - let(:json) { super().merge('timestamp' => time.to_s) } - - it 'creates a point' do - expect { parser }.to change(Point, :count).by(1) - end - end - - context 'when point already exists' do - let(:json) { super().merge('timestamp' => time.to_s) } - - before do - create( - :point, user: import.user, import:, latitude: 12.3456789, longitude: 12.3456789, - timestamp: Time.zone.now.to_i - ) - end - - it 'does not create a point' do - expect { parser }.not_to change(Point, :count) - end - end - - context 'with timestampMs in milliseconds' do - let(:json) { super().merge('timestampMs' => (time.to_f * 1000).to_i.to_s) } - - it 'creates a point using milliseconds timestamp' do - expect { parser }.to change(Point, :count).by(1) - end - end - - context 'with ISO 8601 timestamp' do - let(:json) { super().merge('timestamp' => time.iso8601) } - - it 'parses ISO 8601 timestamp correctly' do - expect { parser }.to change(Point, :count).by(1) - created_point = Point.last - expect(created_point.timestamp).to eq(time.to_i) - end - end - - context 'with timestamp in milliseconds' do - let(:json) { super().merge('timestamp' => (time.to_f * 1000).to_i.to_s) } - - it 'parses millisecond timestamp correctly' do - expect { parser }.to change(Point, :count).by(1) - created_point = Point.last - expect(created_point.timestamp).to eq(time.to_i) - end - end - - context 'with timestamp in seconds' do - let(:json) { super().merge('timestamp' => time.to_i.to_s) } - - it 'parses second timestamp correctly' do - expect { parser }.to change(Point, :count).by(1) - created_point = Point.last - expect(created_point.timestamp).to eq(time.to_i) - end - end - end -end \ No newline at end of file diff --git a/spec/services/gpx/track_parser_spec.rb b/spec/services/gpx/track_parser_spec.rb index b1026143..c5980c91 100644 --- a/spec/services/gpx/track_parser_spec.rb +++ b/spec/services/gpx/track_parser_spec.rb @@ -74,5 +74,15 @@ RSpec.describe Gpx::TrackParser do expect(Point.first.velocity).to eq('2.8') end end + + context 'when file exported from Arc' do + context 'when file has empty tracks' do + let(:file_path) { Rails.root.join('spec/fixtures/files/gpx/arc_example.gpx') } + + it 'creates points' do + expect { parser }.to change { Point.count }.by(6) + end + end + end end end diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb index 908eba72..08b1c60d 100644 --- a/spec/services/imports/create_spec.rb +++ b/spec/services/imports/create_spec.rb @@ -50,21 +50,16 @@ RSpec.describe Imports::Create do end end - it 'schedules visit suggesting' do + xit 'schedules visit suggesting' do Sidekiq::Testing.inline! do expect { service.call }.to have_enqueued_job(VisitSuggestingJob) end end - - it 'schedules reverse geocoding' do - expect { service.call }.to \ - have_enqueued_job(EnqueueBackgroundJob).with('continue_reverse_geocoding', user.id) - end end context 'when import fails' do before do - allow(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: false)) + allow(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_raise(StandardError) end it 'creates a failed notification' do diff --git a/spec/services/jobs/create_spec.rb b/spec/services/jobs/create_spec.rb index fb53e848..84988ff3 100644 --- a/spec/services/jobs/create_spec.rb +++ b/spec/services/jobs/create_spec.rb @@ -4,9 +4,16 @@ require 'rails_helper' RSpec.describe Jobs::Create do describe '#call' do + before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) } + context 'when job_name is start_reverse_geocoding' do let(:user) { create(:user) } - let(:points) { create_list(:point, 4, user:) } + let(:points) do + (1..4).map do |i| + create(:point, user:, timestamp: 1.day.ago + i.minutes) + end + end + let(:job_name) { 'start_reverse_geocoding' } it 'enqueues reverse geocoding for all user points' do @@ -22,8 +29,17 @@ RSpec.describe Jobs::Create do context 'when job_name is continue_reverse_geocoding' do let(:user) { create(:user) } - let(:points_without_address) { create_list(:point, 4, user:, country: nil, city: nil) } - let(:points_with_address) { create_list(:point, 5, user:, country: 'Country', city: 'City') } + let(:points_without_address) do + (1..4).map do |i| + create(:point, user:, country: nil, city: nil, timestamp: 1.day.ago + i.minutes) + end + end + + let(:points_with_address) do + (1..5).map do |i| + create(:point, user:, country: 'Country', city: 'City', timestamp: 1.day.ago + i.minutes) + end + end let(:job_name) { 'continue_reverse_geocoding' } diff --git a/spec/services/points/params_spec.rb b/spec/services/points/params_spec.rb new file mode 100644 index 00000000..62f9b82b --- /dev/null +++ b/spec/services/points/params_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::Params do + describe '#call' do + let(:user) { create(:user) } + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:expected_json) do + { + latitude: 37.74430413, + longitude: -122.40530871, + battery_status: nil, + battery: nil, + timestamp: DateTime.parse('2025-01-17T21:03:01Z'), + altitude: 0, + tracker_id: '8D5D4197-245B-4619-A88B-2049100ADE46', + velocity: 92.088, + ssid: nil, + accuracy: 5, + vertical_accuracy: -1, + course_accuracy: 0, + course: 27.07, + raw_data: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.40530871, 37.74430413] + }, + properties: { + horizontal_accuracy: 5, + track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F', + speed_accuracy: 0, + vertical_accuracy: -1, + course_accuracy: 0, + altitude: 0, + speed: 92.088, + course: 27.07, + timestamp: '2025-01-17T21:03:01Z', + device_id: '8D5D4197-245B-4619-A88B-2049100ADE46' + } + }.with_indifferent_access, + user_id: user.id + } + end + + subject(:params) { described_class.new(json, user.id).call } + + it 'returns an array of points' do + expect(params).to be_an(Array) + expect(params.first).to eq(expected_json) + end + + it 'returns the correct number of points' do + expect(params.size).to eq(6) + end + + it 'returns correct keys' do + expect(params.first.keys).to eq(expected_json.keys) + end + + it 'returns the correct values' do + expect(params.first).to eq(expected_json) + end + end +end diff --git a/spec/services/tasks/imports/google_records_spec.rb b/spec/services/tasks/imports/google_records_spec.rb index 0310dbd1..29fddfdf 100644 --- a/spec/services/tasks/imports/google_records_spec.rb +++ b/spec/services/tasks/imports/google_records_spec.rb @@ -5,10 +5,10 @@ require 'rails_helper' RSpec.describe Tasks::Imports::GoogleRecords do describe '#call' do let(:user) { create(:user) } - let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json').to_s } it 'schedules the Import::GoogleTakeoutJob' do - expect(Import::GoogleTakeoutJob).to receive(:perform_later).exactly(3).times + expect(Import::GoogleTakeoutJob).to receive(:perform_later).exactly(1).time described_class.new(file_path, user.email).call end diff --git a/spec/services/visits/suggest_spec.rb b/spec/services/visits/suggest_spec.rb index 00c20c81..31d76b2e 100644 --- a/spec/services/visits/suggest_spec.rb +++ b/spec/services/visits/suggest_spec.rb @@ -44,8 +44,7 @@ RSpec.describe Visits::Suggest do context 'when reverse geocoding is enabled' do before do - stub_const('REVERSE_GEOCODING_ENABLED', true) - stub_const('PHOTON_API_HOST', 'http://localhost:2323') + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) end it 'reverse geocodes visits' do @@ -57,7 +56,7 @@ RSpec.describe Visits::Suggest do context 'when reverse geocoding is disabled' do before do - stub_const('REVERSE_GEOCODING_ENABLED', false) + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false) end it 'does not reverse geocode visits' do diff --git a/spec/swagger/api/v1/points_controller_spec.rb b/spec/swagger/api/v1/points_controller_spec.rb index cbc31e6d..d3dc087c 100644 --- a/spec/swagger/api/v1/points_controller_spec.rb +++ b/spec/swagger/api/v1/points_controller_spec.rb @@ -58,7 +58,92 @@ describe 'Points API', type: :request do let(:api_key) { user.api_key } let(:start_at) { Time.zone.now - 1.day } let(:end_at) { Time.zone.now } - let(:points) { create_list(:point, 10, user:, timestamp: 2.hours.ago) } + let(:points) do + (1..10).map do |i| + create(:point, user:, timestamp: 2.hours.ago + i.minutes) + end + end + + run_test! + end + end + + post 'Creates a batch of points' do + request_body_example value: { + locations: [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [-122.40530871, 37.74430413] + }, + properties: { + timestamp: '2025-01-17T21:03:01Z', + horizontal_accuracy: 5, + vertical_accuracy: -1, + altitude: 0, + speed: 92.088, + speed_accuracy: 0, + course: 27.07, + course_accuracy: 0, + track_id: '799F32F5-89BB-45FB-A639-098B1B95B09F', + device_id: '8D5D4197-245B-4619-A88B-2049100ADE46' + } + } + ] + } + tags 'Batches' + consumes 'application/json' + parameter name: :locations, in: :body, schema: { + type: :object, + properties: { + type: { type: :string }, + geometry: { + type: :object, + properties: { + type: { type: :string }, + coordinates: { type: :array, items: { type: :number } } + } + }, + properties: { + type: :object, + properties: { + timestamp: { type: :string }, + horizontal_accuracy: { type: :number }, + vertical_accuracy: { type: :number }, + altitude: { type: :number }, + speed: { type: :number }, + speed_accuracy: { type: :number }, + course: { type: :number }, + course_accuracy: { type: :number }, + track_id: { type: :string }, + device_id: { type: :string } + } + } + }, + required: %w[geometry properties] + } + + parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key' + + response '200', 'Batch of points being processed' do + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:params) { json } + let(:locations) { params['locations'] } + let(:api_key) { create(:user).api_key } + + run_test! + end + + response '401', 'Unauthorized' do + let(:file_path) { 'spec/fixtures/files/points/geojson_example.json' } + let(:file) { File.open(file_path) } + let(:json) { JSON.parse(file.read) } + let(:params) { json } + let(:locations) { params['locations'] } + let(:api_key) { 'invalid_api_key' } run_test! end diff --git a/spec/swagger/api/v1/stats_controller_spec.rb b/spec/swagger/api/v1/stats_controller_spec.rb index a6a49c0f..b1fda703 100644 --- a/spec/swagger/api/v1/stats_controller_spec.rb +++ b/spec/swagger/api/v1/stats_controller_spec.rb @@ -57,8 +57,18 @@ describe 'Stats API', type: :request do let!(:user) { create(:user) } let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } - let!(:points_in_2020) { create_list(:point, 85, :with_geodata, timestamp: Time.zone.local(2020), user:) } - let!(:points_in_2021) { create_list(:point, 95, timestamp: Time.zone.local(2021), user:) } + let!(:points_in_2020) do + (1..85).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours, +user:) + end + end + let!(:points_in_2021) do + (1..95).map do |i| + create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2021, 1, 1).to_i + i.hours, +user:) + end + end let(:api_key) { user.api_key } run_test! diff --git a/spec/swagger/api/v1/users_controller_spec.rb b/spec/swagger/api/v1/users_controller_spec.rb new file mode 100644 index 00000000..753f4f08 --- /dev/null +++ b/spec/swagger/api/v1/users_controller_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +describe 'Users API', type: :request do + path '/api/v1/users/me' do + get 'Returns the current user' do + tags 'Users' + consumes 'application/json' + security [bearer_auth: []] + parameter name: 'Authorization', in: :header, type: :string, required: true, + description: 'Bearer token in the format: Bearer {api_key}' + + response '200', 'user found' do + let(:user) { create(:user) } + let(:Authorization) { "Bearer #{user.api_key}" } + + schema type: :object, + properties: { + user: { + type: :object, + properties: { + id: { type: :integer }, + email: { type: :string }, + created_at: { type: :string, format: 'date-time' }, + updated_at: { type: :string, format: 'date-time' }, + api_key: { type: :string }, + theme: { type: :string }, + settings: { + type: :object, + properties: { + immich_url: { type: :string }, + route_opacity: { type: :string }, + immich_api_key: { type: :string }, + live_map_enabled: { type: :boolean }, + fog_of_war_meters: { type: :string }, + preferred_map_layer: { type: :string }, + speed_colored_routes: { type: :boolean }, + meters_between_routes: { type: :string }, + points_rendering_mode: { type: :string }, + minutes_between_routes: { type: :string }, + time_threshold_minutes: { type: :string }, + merge_threshold_minutes: { type: :string }, + speed_colored_polylines: { type: :boolean } + } + }, + admin: { type: :boolean } + } + } + } + + after do |example| + example.metadata[:response][:content] = { + 'application/json' => { + example: JSON.parse(response.body) + } + } + end + + run_test! + end + end + end +end diff --git a/spec/tasks/import_spec.rb b/spec/tasks/import_spec.rb index 4cd785db..0e718f76 100644 --- a/spec/tasks/import_spec.rb +++ b/spec/tasks/import_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe 'import.rake' do - let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/records.json').to_s } let(:user) { create(:user) } it 'calls importing class' do diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index beed0840..7264b64e 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -696,6 +696,87 @@ paths: type: string visit_id: type: string + post: + summary: Creates a batch of points + tags: + - Batches + parameters: + - name: api_key + in: query + required: true + description: API Key + schema: + type: string + responses: + '200': + description: Batch of points being processed + '401': + description: Unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + type: + type: string + geometry: + type: object + properties: + type: + type: string + coordinates: + type: array + items: + type: number + properties: + type: object + properties: + timestamp: + type: string + horizontal_accuracy: + type: number + vertical_accuracy: + type: number + altitude: + type: number + speed: + type: number + speed_accuracy: + type: number + course: + type: number + course_accuracy: + type: number + track_id: + type: string + device_id: + type: string + required: + - geometry + - properties + examples: + '0': + summary: Creates a batch of points + value: + locations: + - type: Feature + geometry: + type: Point + coordinates: + - -122.40530871 + - 37.74430413 + properties: + timestamp: '2025-01-17T21:03:01Z' + horizontal_accuracy: 5 + vertical_accuracy: -1 + altitude: 0 + speed: 92.088 + speed_accuracy: 0 + course: 27.07 + course_accuracy: 0 + track_id: 799F32F5-89BB-45FB-A639-098B1B95B09F + device_id: 8D5D4197-245B-4619-A88B-2049100ADE46 "/api/v1/points/{id}": delete: summary: Deletes a point @@ -892,6 +973,23 @@ paths: - totalCountriesVisited - totalCitiesVisited - yearlyStats + "/api/v1/users/me": + get: + summary: Returns the current user + tags: + - Users + security: + - bearer_auth: [] + parameters: + - name: Authorization + in: header + required: true + description: 'Bearer token in the format: Bearer {api_key}' + schema: + type: string + responses: + '200': + description: user found servers: - url: http://{defaultHost} variables: