diff --git a/.app_version b/.app_version index 85e60ed1..731b95d7 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.34.0 +0.35.1 diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 3c04cdb6..23cb3a36 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -96,7 +96,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: ./docker/Dockerfile.dev + file: ./docker/Dockerfile push: true tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 3f826b56..091c325f 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +/e2e/temp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d058ab3..d0c5e380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,22 @@ 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/). -# [UNRELEASED] +## Unreleased + +## Added + +- Support for KML file uploads. #350 +- Added a commented line in the `docker-compose.yml` file to use an alternative PostGIS image for ARM architecture. + +## Fixed + +- The map settings panel is now scrollable + +--- + +## Changed + +- Internal redis settings updated to implement support for connecting to Redis via unix socket. #1706 - Implemented authentication via GitHub and Google for Dawarich Cloud. - Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66 @@ -16,6 +31,63 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - [ ] Disable GitHub and Google authentication for self-hosted Dawarich - [ ] In selfhosted env, no registrations are allowed, we need to account OIDC into that + +# [0.35.1] - 2025-11-09 + +## Fixed + +- StrongMigration issue #1931 + + +# [0.35.0] - 2025-11-09 + +⚠️ Important ⚠️ + +The default `docker-compose.yml` file has been updated to provide sensible defaults for self-hosted production environments. This should not break existing setups, but it's recommended to review your `docker-compose.yml` file and update it accordingly. + +You can now set `RAILS_ENV` environment variable to `production` to run Dawarich in production mode. + +## Added + +- Selection tool on the map now can select points that user can delete in bulk. #433 + +## Fixed + +- Taiwan flag is now shown on its own instead of in combination with China flag. +- On the registration page and other user forms, if something goes wrong, error messages are now shown to the user. +- Leaving family, deleting family and cancelling invitations now prompt confirmation dialog to prevent accidental actions. +- Each pending family invitation now also contains a link to share with the invitee. + +## Changed + +- Removed useless system tests and cover map functionality with Playwright e2e tests instead. +- S3 storage now can be used in self-hosted instances as well. Set STORAGE_BACKEND environment variable to `s3` and provide `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_BUCKET` and `AWS_ENDPOINT_URL` environment variables to configure it. +- Number of family members on self-hosted instances is no longer limited. #1918 +- Export to GPX now adds speed and course to each point if they are available. +- `docker-compose.yml` file updated to provide sensible defaults for self-hosted production environment. +- `.env.example` file added with default environment variables. +- Single Dockerfile introduced so Dawarich could be run in self-hosted mode in production environment. + +# [0.34.2] - 2025-10-31 + +## Fixed + +- Fixed a bug in UTM trackable concern. #1909 + +# [0.34.1] - 2025-10-30 + +## Fixed + +- Broken Stats page for users with no reverse geocoding enabled. #1877 + +## Changed + +- Date navigation on the map page is no longer shown as floating panel. It is now part of the top navigation bar to prevent overlapping with other map controls. #1894 #1881 + +## Added + +- [Dawarich Cloud] Added support for UTM parameters during user registration. UTM parameters will be stored with the user record for marketing analytics purposes. + # [0.34.0] - 2025-10-10 ## The Family release diff --git a/Gemfile b/Gemfile index 3a20abea..219fa0e2 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } ruby File.read('.ruby-version').strip -gem 'activerecord-postgis-adapter' +gem 'activerecord-postgis-adapter', '~> 11.0' # https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40 gem 'aws-sdk-core', '~> 3.215.1', require: false gem 'aws-sdk-kms', '~> 1.96.0', require: false @@ -33,12 +33,12 @@ gem 'pg' gem 'prometheus_exporter' gem 'puma' gem 'pundit', '>= 2.5.1' -gem 'rails', '~> 8.0', '>= 8.0.3' +gem 'rails', '~> 8.0' gem 'rails_icons' gem 'redis' gem 'rexml' gem 'rgeo' -gem 'rgeo-activerecord' +gem 'rgeo-activerecord', '~> 8.0.0' gem 'rgeo-geojson' gem 'rqrcode', '~> 3.0' gem 'rswag-api' @@ -52,7 +52,6 @@ gem 'sidekiq-limit_fetch' gem 'sprockets-rails' gem 'stackprof' gem 'stimulus-rails' -gem 'strong_migrations', '>= 2.4.0' gem 'tailwindcss-rails', '= 3.3.2' gem 'turbo-rails', '>= 2.0.17' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] @@ -84,4 +83,5 @@ group :development do gem 'database_consistency', '>= 2.0.5', require: false gem 'foreman' gem 'rubocop-rails', '>= 2.33.4', require: false + gem 'strong_migrations', '>= 2.4.0' end diff --git a/Gemfile.lock b/Gemfile.lock index f64a4477..dadaade0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -109,11 +109,10 @@ GEM base64 (0.3.0) bcrypt (3.1.20) benchmark (0.4.1) - bigdecimal (3.2.3) - bindata (2.5.1) + bigdecimal (3.3.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (7.1.0) racc builder (3.3.0) bundler-audit (0.9.2) @@ -142,12 +141,12 @@ GEM tzinfo unicode (>= 0.4.4.5) csv (3.3.4) - data_migrate (11.3.0) + data_migrate (11.3.1) activerecord (>= 6.1) railties (>= 6.1) database_consistency (2.0.6) activerecord (>= 3.2) - date (3.4.1) + date (3.5.0) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) @@ -164,9 +163,7 @@ GEM dotenv (= 3.1.8) railties (>= 6.1) drb (2.2.3) - email_validator (2.2.4) - activemodel - erb (5.0.2) + erb (5.1.3) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -272,7 +269,7 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) + minitest (5.26.0) msgpack (1.7.3) multi_json (1.15.0) multi_xml (0.7.1) @@ -362,7 +359,7 @@ GEM pg (1.6.2-arm64-darwin) pg (1.6.2-x86_64-darwin) pg (1.6.2-x86_64-linux) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) prism (1.5.1) @@ -386,18 +383,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.2) - rack-oauth2 (2.2.1) - activesupport - attr_required - faraday (~> 2.0) - faraday-follow_redirects - json-jwt (>= 1.11.0) - rack (>= 2.1.0) - rack-protection (4.2.1) - base64 (>= 0.1.0) - logger (>= 1.6.0) - rack (>= 3.0.0, < 4) + rack (3.2.3) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -439,10 +425,11 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.0) - rdoc (6.14.2) + rake (13.3.1) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort redis (5.4.0) redis-client (>= 0.22.0) redis-client (0.24.0) @@ -484,17 +471,17 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.3) - rswag-api (2.16.0) - activesupport (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) - rswag-specs (2.16.0) - activesupport (>= 5.2, < 8.1) - json-schema (>= 2.2, < 6.0) - railties (>= 5.2, < 8.1) + rswag-api (2.17.0) + activesupport (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) + rswag-specs (2.17.0) + activesupport (>= 5.2, < 8.2) + json-schema (>= 2.2, < 7.0) + railties (>= 5.2, < 8.2) rspec-core (>= 2.14) - rswag-ui (2.16.0) - actionpack (>= 5.2, < 8.1) - railties (>= 5.2, < 8.1) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -524,10 +511,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (5.28.0) - railties (>= 5.0) - sentry-ruby (~> 5.28.0) - sentry-ruby (5.28.0) + sentry-rails (6.0.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.0.0) + sentry-ruby (6.0.0) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) shoulda-matchers (6.5.0) @@ -567,15 +554,10 @@ GEM stringio (3.1.7) strong_migrations (2.5.1) activerecord (>= 7.1) - super_diff (0.16.0) + super_diff (0.17.0) attr_extras (>= 6.2.4) diff-lcs patience_diff - swd (2.0.3) - activesupport (>= 3) - attr_required (>= 0.0.5) - faraday (~> 2.0) - faraday-follow_redirects tailwindcss-rails (3.3.2) railties (>= 7.0.0) tailwindcss-ruby (~> 3.0) @@ -586,7 +568,7 @@ GEM tailwindcss-ruby (3.4.17-x86_64-darwin) tailwindcss-ruby (3.4.17-x86_64-linux) thor (1.4.0) - timeout (0.4.3) + timeout (0.4.4) tsort (0.2.0) turbo-rails (2.0.17) actionpack (>= 7.1.0) @@ -597,7 +579,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) @@ -632,7 +614,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - activerecord-postgis-adapter + activerecord-postgis-adapter (~> 11.0) aws-sdk-core (~> 3.215.1) aws-sdk-kms (~> 1.96.0) aws-sdk-s3 (~> 1.177.0) @@ -671,12 +653,12 @@ DEPENDENCIES pry-rails puma pundit (>= 2.5.1) - rails (~> 8.0, >= 8.0.3) + rails (~> 8.0) rails_icons redis rexml rgeo - rgeo-activerecord + rgeo-activerecord (~> 8.0.0) rgeo-geojson rqrcode (~> 3.0) rspec-rails (>= 8.0.1) diff --git a/README.md b/README.md index 797e2177..1f1af5ec 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ Simply install one of the supported apps on your device and configure it to send ⏹️ **To stop the app**, press `Ctrl+C`. +You can use default values or create a `.env` file based on `.env.example` to customize your setup. + --- ## 🔧 How to Install Dawarich diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 780b039d..be4da8fc 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.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{display:inline-flex;position:relative}.avatar>div{aspect-ratio:1/1;display:block;overflow:hidden}.avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.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}.btn-circle{border-radius:9999px;height:3rem;padding:0;width:3rem}: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}:root .countdown{line-height:1em}.countdown{display:inline-flex}.countdown>*{display:inline-block;height:1em;overflow-y:hidden}.countdown>:before{content:"00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";position:relative;text-align:center;top:calc(var(--value)*-1em);transition:all 1s cubic-bezier(1,0,0,1);white-space:pre}.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)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.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:hover{--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: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-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{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:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{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: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!important;column-gap:1rem!important;display:grid!important;font-size:.875rem!important;grid-auto-flow:row!important;line-height:1.25rem!important;place-items:start!important;row-gap:2.5rem!important;width:100%!important}.footer{-moz-column-gap:1rem;column-gap:1rem;display:grid;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;place-items:start;row-gap:2.5rem;width:100%}.\!footer>*{display:grid!important;gap:.5rem!important;place-items:start!important}.footer>*{display:grid;gap:.5rem;place-items:start}@media (min-width:48rem){.\!footer{grid-auto-flow:column!important}.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-overlay,.hero>*{grid-column-start:1;grid-row-start:1}.hero-overlay{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));height:100%;width:100%;--tw-bg-opacity:0.5}.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!important;-moz-appearance:none!important;appearance:none!important;border-color:transparent!important;border-radius:var(--rounded-btn,.5rem)!important;border-width:1px!important;flex-shrink:1!important;font-size:1rem!important;height:3rem!important;line-height:2!important;line-height:1.5rem!important;padding-left:1rem!important;padding-right:1rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))!important}.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[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem!important;margin-top:-1rem!important;margin-inline-end:-1rem!important}.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}.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-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.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}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.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-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)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/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{display:inline-flex;position:relative}.avatar>div{aspect-ratio:1/1;display:block;overflow:hidden}.avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.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}.btn-circle{border-radius:9999px;height:3rem;padding:0;width:3rem}: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}.collapse:not(td):not(tr):not(colgroup){visibility:visible}.collapse{border-radius:var(--rounded-box,1rem);display:grid;grid-template-rows:auto 0fr;overflow:hidden;position:relative;transition:grid-template-rows .2s;width:100%}.collapse-content,.collapse-title,.collapse>input[type=checkbox],.collapse>input[type=radio]{grid-column-start:1;grid-row-start:1}.collapse>input[type=checkbox],.collapse>input[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;opacity:0}.collapse-content{cursor:unset;grid-column-start:1;grid-row-start:2;min-height:0;padding-left:1rem;padding-right:1rem;transition:visibility .2s;transition:padding .2s ease-out,background-color .2s ease-out;visibility:hidden}.collapse-open,.collapse:focus:not(.collapse-close),.collapse[open]{grid-template-rows:auto 1fr}.collapse:not(.collapse-close):has(>input[type=checkbox]:checked),.collapse:not(.collapse-close):has(>input[type=radio]:checked){grid-template-rows:auto 1fr}.collapse-open>.collapse-content,.collapse:focus:not(.collapse-close)>.collapse-content,.collapse:not(.collapse-close)>input[type=checkbox]:checked~.collapse-content,.collapse:not(.collapse-close)>input[type=radio]:checked~.collapse-content,.collapse[open]>.collapse-content{min-height:-moz-fit-content;min-height:fit-content;visibility:visible}:root .countdown{line-height:1em}.countdown{display:inline-flex}.countdown>*{display:inline-block;height:1em;overflow-y:hidden}.countdown>:before{content:"00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";position:relative;text-align:center;top:calc(var(--value)*-1em);transition:all 1s cubic-bezier(1,0,0,1);white-space:pre}.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)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.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:hover{--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: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-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{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:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{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: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!important;column-gap:1rem!important;display:grid!important;font-size:.875rem!important;grid-auto-flow:row!important;line-height:1.25rem!important;place-items:start!important;row-gap:2.5rem!important;width:100%!important}.footer{-moz-column-gap:1rem;column-gap:1rem;display:grid;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;place-items:start;row-gap:2.5rem;width:100%}.\!footer>*{display:grid!important;gap:.5rem!important;place-items:start!important}.footer>*{display:grid;gap:.5rem;place-items:start}@media (min-width:48rem){.\!footer{grid-auto-flow:column!important}.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-overlay,.hero>*{grid-column-start:1;grid-row-start:1}.hero-overlay{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));height:100%;width:100%;--tw-bg-opacity:0.5}.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!important;-moz-appearance:none!important;appearance:none!important;border-color:transparent!important;border-radius:var(--rounded-btn,.5rem)!important;border-width:1px!important;flex-shrink:1!important;font-size:1rem!important;height:3rem!important;line-height:2!important;line-height:1.5rem!important;padding-left:1rem!important;padding-right:1rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))!important}.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[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem!important;margin-top:-1rem!important;margin-inline-end:-1rem!important}.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-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}.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-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.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}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.steps{counter-reset:step;display:inline-grid;grid-auto-columns:1fr;grid-auto-flow:column;overflow:hidden;overflow-x:auto}.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-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)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/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}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.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))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-ghost{--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:1;color:var(--fallback-bc,oklch(var(--bc)/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{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--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)}.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{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/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{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/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}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.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}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.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!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.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:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.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-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary: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))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.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,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.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:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.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-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.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-sm{width:1.25rem}.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-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.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!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;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))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;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{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;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))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;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::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))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@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-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/1))}.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-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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)}.toast>*{animation:toast-pop .25s ease-out}@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-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--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)))}.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}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.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-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.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-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.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}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[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}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.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)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y: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))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--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))}.toast:where(.toast-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] .toast:where(.toast-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))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--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))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y: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))}.toast:where(.toast-middle){bottom:auto;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))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y: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))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.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%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.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%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.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}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.right-0{right:0}.right-2{right:.5rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-20{top:5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-2{margin:.5rem}.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}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.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-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.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-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.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-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.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}.flex-grow{flex-grow:1}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{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 bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,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}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.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-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(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)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.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-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.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-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/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-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/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-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/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{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/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)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-opacity-95{--tw-bg-opacity:0.95}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.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}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.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-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.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}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{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-base{font-size:1rem;line-height:1.5rem}.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-normal{font-weight:400}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.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-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.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-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/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-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/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-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/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-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/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}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.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-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.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);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.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-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;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}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@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}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:scale-105:hover,.hover\:scale-\[1\.02\]:hover{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\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.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-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--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,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.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}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,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\:\!block{display:block!important}.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\: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\:items-end{align-items:flex-end}.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\: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)))}.lg\:text-left{text-align:left}}@media (min-width:1280px){.xl\:inline{display:inline}.xl\:hidden{display:none}} \ 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}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.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))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-ghost{--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:1;color:var(--fallback-bc,oklch(var(--bc)/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{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--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)}.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{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/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{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/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}}details.collapse{width:100%}details.collapse summary{display:block;outline:2px solid transparent;outline-offset:2px;position:relative}details.collapse summary::-webkit-details-marker{display:none}.collapse:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse:has(.collapse-title:focus-visible),.collapse:has(>input[type=checkbox]:focus-visible),.collapse:has(>input[type=radio]:focus-visible){outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse-arrow>.collapse-title:after{--tw-translate-y:-100%;--tw-rotate:45deg;box-shadow:2px 2px;content:"";top:1.9rem;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));transform-origin:75% 75%;transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse-arrow>.collapse-title:after,.collapse-plus>.collapse-title:after{display:block;height:.5rem;inset-inline-end:1.4rem;pointer-events:none;position:absolute;transition-property:all;width:.5rem}.collapse-plus>.collapse-title:after{content:"+";top:.9rem;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse:not(.collapse-open):not(.collapse-close)>.collapse-title,.collapse:not(.collapse-open):not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-open):not(.collapse-close)>input[type=radio]:not(:checked){cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open])>.collapse-title{cursor:unset}.collapse-title{position:relative}:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){z-index:1}.collapse-title,:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){min-height:3.75rem;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;width:100%}.collapse-open>:where(.collapse-content),.collapse:focus:not(.collapse-close)>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input[type=checkbox]:checked~.collapse-content),.collapse:not(.collapse-close)>:where(input[type=radio]:checked~.collapse-content),.collapse[open]>:where(.collapse-content){padding-bottom:1rem;transition:padding .2s ease-out,background-color .2s ease-out}.collapse-arrow:focus:not(.collapse-close)>.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse-open.collapse-arrow>.collapse-title:after,.collapse[open].collapse-arrow>.collapse-title:after{--tw-translate-y:-50%;--tw-rotate:225deg;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))}.collapse-open.collapse-plus>.collapse-title:after,.collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse[open].collapse-plus>.collapse-title:after{content:"−"}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.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}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.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!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.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:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary: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))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.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,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.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:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.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-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.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-sm{width:1.25rem}.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-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.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!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;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))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;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{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;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))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;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::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))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@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-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/1))}.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-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/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)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--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)))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--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)))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.steps .step-info+.step-info:before,.steps .step-info:after{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.steps .step-info:after{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.steps .step-success+.step-success:before,.steps .step-success:after{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.steps .step-success:after{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.steps .step-warning+.step-warning:before,.steps .step-warning:after{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.steps .step-warning:after{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.steps .step-error+.step-error:before,.steps .step-error:after{--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)))}.steps .step-error:after{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.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)}.toast>*{animation:toast-pop .25s ease-out}@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-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--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)))}.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}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.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-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.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-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.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}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[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-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}[type=radio].radio-sm{height:1.25rem;width:1.25rem}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.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)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y: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))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--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))}.toast:where(.toast-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] .toast:where(.toast-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))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--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))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y: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))}.toast:where(.toast-middle){bottom:auto;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))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y: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))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.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%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.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%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.right-0{right:0}.right-2{right:.5rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.col-span-2{grid-column:span 2/span 2}.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}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.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-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.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-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-80{width:20rem}.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-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.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}.flex-grow{flex-grow:1}.transform{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 bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,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}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.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-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(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)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.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-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.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-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/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-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/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-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/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{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/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)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.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}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.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-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.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}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{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-base{font-size:1rem;line-height:1.5rem}.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-normal{font-weight:400}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.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-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.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-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/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-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/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-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/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-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/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}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.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-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.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);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{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)}.grayscale{--tw-grayscale:grayscale(100%)}.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-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;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}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);cursor:default;height:auto;max-height:calc(100% - 20px);opacity:0;position:absolute;right:70px;top:10px;transform:scale(.95);transition:opacity .2s ease-in-out,transform .2s ease-in-out,visibility .2s;visibility:hidden;width:24rem;z-index:450}.leaflet-drawer *{cursor:default}.leaflet-drawer .btn,.leaflet-drawer a,.leaflet-drawer button,.leaflet-drawer input[type=checkbox]{cursor:pointer}.leaflet-drawer.open{opacity:1;transform:scale(1);visibility:visible}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{z-index:500}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{width:100%}@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))}@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}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:scale-105:hover,.hover\:scale-\[1\.02\]:hover{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\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.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-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--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,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.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}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,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\:\!block{display:block!important}.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\: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\:items-end{align-items:flex-end}.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\: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)))}.lg\:text-left{text-align:left}} \ No newline at end of file diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 52e272ff..55b35e61 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -27,9 +27,13 @@ /* Style for the settings panel */ .leaflet-settings-panel { background-color: white; - padding: 10px; - border: 1px solid #ccc; + border-radius: 4px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); + position: absolute !important; + top: 10px !important; + left: 60px !important; + transform: none; + z-index: 1000; } .leaflet-settings-panel label { diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 4438d527..af74babb 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -76,33 +76,46 @@ /* Drawer Panel Styles */ .leaflet-drawer { position: absolute; - top: 0; - right: 0; - width: 338px; - height: 100%; + top: 10px; + right: 70px; /* Position to the left of the control buttons with margin */ + width: 24rem; + max-height: calc(100% - 20px); background: rgba(255, 255, 255, 0.5); - transform: translateX(100%); - transition: transform 0.3s ease-in-out; + border-radius: 8px; + opacity: 0; + visibility: hidden; + transform: scale(0.95); + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out, visibility 0.2s; z-index: 450; - box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + height: auto; /* Make height fit content */ + cursor: default; /* Override map cursor */ +} + +.leaflet-drawer * { + cursor: default; /* Ensure all children have default cursor */ +} + +.leaflet-drawer a, +.leaflet-drawer button, +.leaflet-drawer .btn, +.leaflet-drawer input[type="checkbox"] { + cursor: pointer; /* Interactive elements get pointer cursor */ } .leaflet-drawer.open { - transform: translateX(0); + opacity: 1; + visibility: visible; + transform: scale(1); } -/* Controls transition */ +/* Controls remain in place - no transition needed */ .leaflet-control-layers, .leaflet-control-button, .toggle-panel-button { - transition: right 0.3s ease-in-out; z-index: 500; } -.controls-shifted { - right: 338px !important; -} - /* Selection Tool Styles */ .leaflet-control-custom { background-color: white; @@ -127,6 +140,5 @@ /* Cancel Selection Button */ #cancel-selection-button { - margin-bottom: 1rem; width: 100%; } diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index 6dd2cf93..08f7097c 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::PointsController < ApiController - before_action :authenticate_active_api_user!, only: %i[create update destroy] + before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy] before_action :validate_points_limit, only: %i[create] def index @@ -45,6 +45,16 @@ class Api::V1::PointsController < ApiController render json: { message: 'Point deleted successfully' } end + def bulk_destroy + point_ids = bulk_destroy_params[:point_ids] + + render json: { error: 'No points selected' }, status: :unprocessable_entity and return if point_ids.blank? + + deleted_count = current_api_user.points.where(id: point_ids).destroy_all.count + + render json: { message: 'Points were successfully destroyed', count: deleted_count }, status: :ok + end + private def point_params @@ -55,6 +65,10 @@ class Api::V1::PointsController < ApiController params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {}) end + def bulk_destroy_params + params.permit(point_ids: []) + end + def point_serializer params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer end diff --git a/app/controllers/concerns/utm_trackable.rb b/app/controllers/concerns/utm_trackable.rb new file mode 100644 index 00000000..a0d5502b --- /dev/null +++ b/app/controllers/concerns/utm_trackable.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module UtmTrackable + extend ActiveSupport::Concern + + UTM_PARAMS = %w[utm_source utm_medium utm_campaign utm_term utm_content].freeze + + def store_utm_params + UTM_PARAMS.each do |param| + session[param] = params[param] if params[param].present? + end + end + + def assign_utm_params(record) + utm_data = extract_utm_data_from_session + + return unless utm_data.any? + + record.update_columns(utm_data) + clear_utm_session + end + + private + + def extract_utm_data_from_session + UTM_PARAMS.each_with_object({}) do |param, hash| + hash[param] = session[param] if session[param].present? + end + end + + def clear_utm_session + UTM_PARAMS.each { |param| session.delete(param) } + end +end diff --git a/app/controllers/families_controller.rb b/app/controllers/families_controller.rb index 5ce52f56..c0ccade3 100644 --- a/app/controllers/families_controller.rb +++ b/app/controllers/families_controller.rb @@ -77,6 +77,8 @@ class FamiliesController < ApplicationController end def update_location_sharing + authorize @family, :update_location_sharing? + result = Families::UpdateLocationSharing.new( user: current_user, enabled: params[:enabled], diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index fd6a448c..f01254a8 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true class Users::RegistrationsController < Devise::RegistrationsController + include UtmTrackable + before_action :set_invitation, only: %i[new create] before_action :check_registration_allowed, only: %i[new create] + before_action :store_utm_params, only: %i[new], unless: -> { DawarichSettings.self_hosted? } def new build_resource({}) @@ -16,8 +19,9 @@ class Users::RegistrationsController < Devise::RegistrationsController def create super do |resource| - if resource.persisted? && @invitation - accept_invitation_for_user(resource) + if resource.persisted? + assign_utm_params(resource) + accept_invitation_for_user(resource) if @invitation end end end @@ -47,7 +51,7 @@ class Users::RegistrationsController < Devise::RegistrationsController end def set_invitation - return unless invitation_token.present? + return if invitation_token.blank? @invitation = Family::Invitation.find_by(token: invitation_token) end @@ -65,8 +69,8 @@ class Users::RegistrationsController < Devise::RegistrationsController def invitation_token @invitation_token ||= params[:invitation_token] || - params.dig(:user, :invitation_token) || - session[:invitation_token] + params.dig(:user, :invitation_token) || + session[:invitation_token] end def accept_invitation_for_user(user) @@ -80,11 +84,13 @@ class Users::RegistrationsController < Devise::RegistrationsController if service.call flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family." else - flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}" + flash[:alert] = + "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}" end rescue StandardError => e Rails.logger.error "Error accepting invitation during registration: #{e.message}" - flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again." + flash[:alert] = + 'Account created successfully, but there was an issue accepting the invitation. Please try accepting it again.' end def sign_up_params diff --git a/app/helpers/country_flag_helper.rb b/app/helpers/country_flag_helper.rb index cfa711f0..912a1a53 100644 --- a/app/helpers/country_flag_helper.rb +++ b/app/helpers/country_flag_helper.rb @@ -3,13 +3,14 @@ module CountryFlagHelper def country_flag(country_name) country_code = country_to_code(country_name) - return "" unless country_code + return '' unless country_code + + country_code = 'TW' if country_code == 'CN-TW' # Convert country code to regional indicator symbols (flag emoji) - country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join + country_code.upcase.each_char.map { |c| (c.ord + 127_397).chr(Encoding::UTF_8) }.join end - private def country_to_code(country_name) diff --git a/app/javascript/controllers/add_visit_controller.js b/app/javascript/controllers/add_visit_controller.js index b1427993..e74e678b 100644 --- a/app/javascript/controllers/add_visit_controller.js +++ b/app/javascript/controllers/add_visit_controller.js @@ -148,6 +148,10 @@ export default class extends Controller { if (this.currentPopup) { this.map.closePopup(this.currentPopup); this.currentPopup = null; + } else { + console.warn('No currentPopup reference found'); + // Fallback: try to close any open popup + this.map.closePopup(); } } @@ -263,7 +267,10 @@ export default class extends Controller { } if (cancelButton) { - cancelButton.addEventListener('click', () => { + cancelButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.exitAddVisitMode(this.addVisitButton); }); } @@ -346,8 +353,6 @@ export default class extends Controller { } addCreatedVisitToMap(visitData, latitude, longitude) { - console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData }); - const mapsController = document.querySelector('[data-controller*="maps"]'); if (!mapsController) { console.log('Could not find maps controller element'); @@ -357,6 +362,7 @@ export default class extends Controller { const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps'); if (!stimulusController || !stimulusController.visitsManager) { console.log('Could not find maps controller or visits manager'); + return; } @@ -376,16 +382,10 @@ export default class extends Controller { // Add the circle to the confirmed visits layer visitsManager.confirmedVisitCircles.addLayer(circle); - console.log('✅ Added newly created confirmed visit circle to layer'); - console.log('Confirmed visits layer info:', { - layerCount: visitsManager.confirmedVisitCircles.getLayers().length, - isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles) - }); // Make sure the layer is visible on the map if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) { this.map.addLayer(visitsManager.confirmedVisitCircles); - console.log('✅ Added confirmed visits layer to map'); } // Check if the layer control has the confirmed visits layer enabled @@ -411,9 +411,7 @@ export default class extends Controller { inputs.forEach(input => { const label = input.nextElementSibling; if (label && label.textContent.trim().includes('Confirmed Visits')) { - console.log('Found Confirmed Visits checkbox, current state:', input.checked); if (!input.checked) { - console.log('Enabling Confirmed Visits layer via checkbox'); input.checked = true; input.dispatchEvent(new Event('change', { bubbles: true })); } diff --git a/app/javascript/controllers/direct_upload_controller.js b/app/javascript/controllers/direct_upload_controller.js index f278442b..1ef18a0c 100644 --- a/app/javascript/controllers/direct_upload_controller.js +++ b/app/javascript/controllers/direct_upload_controller.js @@ -29,7 +29,7 @@ export default class extends Controller { if (this.isUploading) { // If still uploading, prevent submission event.preventDefault() - console.log("Form submission prevented during upload") + return } @@ -41,7 +41,7 @@ export default class extends Controller { const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]') if (signedIds.length === 0) { event.preventDefault() - console.log("No files uploaded yet") + alert("Please select and upload files first") } else { console.log(`Submitting form with ${signedIds.length} uploaded files`) @@ -78,7 +78,6 @@ export default class extends Controller { } } - console.log(`Uploading ${files.length} files`) this.isUploading = true // Disable submit button during upload @@ -124,8 +123,6 @@ export default class extends Controller { // Add the progress wrapper AFTER the file input field but BEFORE the submit button this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget) - console.log("Progress bar created and inserted before submit button") - let uploadCount = 0 const totalFiles = files.length @@ -137,17 +134,13 @@ export default class extends Controller { }); Array.from(files).forEach(file => { - console.log(`Starting upload for ${file.name}`) const upload = new DirectUpload(file, this.urlValue, this) upload.create((error, blob) => { uploadCount++ if (error) { - console.error("Error uploading file:", error) - // Show error to user using flash showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`) } else { - console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`) // Create a hidden field with the correct name const hiddenField = document.createElement("input") @@ -155,8 +148,6 @@ export default class extends Controller { hiddenField.setAttribute("name", "import[files][]") hiddenField.setAttribute("value", blob.signed_id) this.element.appendChild(hiddenField) - - console.log("Added hidden field with signed ID:", blob.signed_id) } // Enable submit button when all uploads are complete @@ -186,8 +177,6 @@ export default class extends Controller { } } this.isUploading = false - console.log("All uploads completed") - console.log(`Ready to submit with ${successfulUploads} files`) } }) }) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index d2ad1883..c76ce12d 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -208,7 +208,7 @@ export default class extends BaseController { this.addInfoToggleButton(); // Initialize the visits manager - this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme); + this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme, this); // Expose visits manager globally for location search integration window.visitsManager = this.visitsManager; @@ -712,6 +712,9 @@ export default class extends BaseController { if (this.map.hasLayer(this.fogOverlay)) { this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); } + + // Show success message + showFlashMessage('notice', 'Point deleted successfully'); }) .catch(error => { console.error('There was a problem with the delete request:', error); @@ -952,100 +955,141 @@ export default class extends BaseController { // Form HTML div.innerHTML = ` -
- -
- - - + +
+ +
+ + +
- -
- - +
+ +
+ + +
- -
- - +
+ +
+ + +
- - -
- - +
+ +
+ + +
- - -
- - +
+ +
+ + +
- - -
- - +
+ +
+ + +
- - -
- - +
+ +
+ + +
- - - - - - - - - - - -
- - +
+ +
+ + +
- -
+
+ +
- +
+ +
+ +
+ +
+ + +
+ +
+ +
+ + `; // Style the panel with theme-aware styling applyThemeToPanel(div, this.userTheme); div.style.padding = '10px'; + div.style.width = '220px'; + div.style.maxHeight = 'calc(60vh - 20px)'; + div.style.overflowY = 'auto'; // Prevent map interactions when interacting with the form L.DomEvent.disableClickPropagation(div); + L.DomEvent.disableScrollPropagation(div); // Attach event listener to the "Edit Gradient" button: const editBtn = div.querySelector("#edit-gradient-btn"); diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index b9c15d2f..a0a36813 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -122,9 +122,8 @@ export default class extends BaseController { }); }); - // Add markers and route + // Add route (no markers on trip forms) if (this.coordinates?.length > 0) { - this.addMarkers() this.addPolyline() this.fitMapToBounds() } @@ -246,9 +245,8 @@ export default class extends BaseController { this.polylinesLayer.clearLayers() this.photoMarkers.clearLayers() - // Add new markers and route if coordinates exist + // Add only polyline (no markers) when coordinates exist if (this.coordinates?.length > 0) { - this.addMarkers() this.addPolyline() this.fitMapToBounds() } diff --git a/app/javascript/maps/visits.js b/app/javascript/maps/visits.js index 86daa589..a05e0963 100644 --- a/app/javascript/maps/visits.js +++ b/app/javascript/maps/visits.js @@ -1,14 +1,16 @@ import L from "leaflet"; import { showFlashMessage } from "./helpers"; +import { createPolylinesLayer } from "./polylines"; /** * Manages visits functionality including displaying, fetching, and interacting with visits */ export class VisitsManager { - constructor(map, apiKey, userTheme = 'dark') { + constructor(map, apiKey, userTheme = 'dark', mapsController = null) { this.map = map; this.apiKey = apiKey; this.userTheme = userTheme; + this.mapsController = mapsController; // Create custom panes for different visit types // Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700 @@ -218,15 +220,20 @@ export class VisitsManager { // Set selection as active to ensure date summary is displayed this.isSelectionActive = true; - this.displayVisits(visits); - - // Make sure the drawer is open + // Make sure the drawer is open FIRST, before displaying visits if (!this.drawerOpen) { this.toggleDrawer(); } - // Add cancel selection button to the drawer - this.addSelectionCancelButton(); + // Now display visits in the drawer + this.displayVisits(visits); + + // Add cancel selection button to the drawer AFTER displayVisits + // This needs to be after because displayVisits sets innerHTML which would wipe out the buttons + // Use setTimeout to ensure DOM has fully updated + setTimeout(() => { + this.addSelectionCancelButton(); + }, 0); } catch (error) { console.error('Error fetching visits in selection:', error); @@ -362,7 +369,7 @@ export class VisitsManager { const visitsCount = dateGroups[dateStr].count || 0; return ` -
+
${dateStr}
${pointsCount > 0 ? `
${pointsCount} pts
` : ''} @@ -372,14 +379,18 @@ export class VisitsManager { `; }).join(''); - // Create the whole panel + // Create the whole panel with collapsible content return ` -
-

Data in Selected Area

-
- ${dateItems} +
+ + Data in Selected Area + +
+
+ ${dateItems} +
-
+ `; } @@ -388,18 +399,207 @@ export class VisitsManager { */ addSelectionCancelButton() { const container = document.getElementById('visits-list'); - if (!container) return; + if (!container) { + console.error('addSelectionCancelButton: visits-list container not found'); + return; + } - // Add cancel button at the top of the drawer if it doesn't exist - if (!document.getElementById('cancel-selection-button')) { - const cancelButton = document.createElement('button'); - cancelButton.id = 'cancel-selection-button'; - cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full'; - cancelButton.textContent = 'Cancel Area Selection'; - cancelButton.onclick = () => this.clearSelection(); + // Remove any existing button container first to avoid duplicates + const existingButtonContainer = document.getElementById('selection-button-container'); + if (existingButtonContainer) { + existingButtonContainer.remove(); + } - // Insert at the beginning of the container - container.insertBefore(cancelButton, container.firstChild); + // Create a button container + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'flex flex-col gap-2 mb-4'; + buttonContainer.id = 'selection-button-container'; + + // Cancel button + const cancelButton = document.createElement('button'); + cancelButton.id = 'cancel-selection-button'; + cancelButton.className = 'btn btn-sm btn-warning w-full'; + cancelButton.textContent = 'Cancel Selection'; + cancelButton.onclick = () => this.clearSelection(); + + // Delete all selected points button + const deleteButton = document.createElement('button'); + deleteButton.id = 'delete-selection-button'; + deleteButton.className = 'btn btn-sm btn-error w-full'; + deleteButton.innerHTML = 'Delete Points'; + deleteButton.onclick = () => this.deleteSelectedPoints(); + + // Add count badge if we have selected points + if (this.selectedPoints && this.selectedPoints.length > 0) { + const badge = document.createElement('span'); + badge.className = 'badge badge-sm ml-1'; + badge.textContent = this.selectedPoints.length; + deleteButton.appendChild(badge); + } + + buttonContainer.appendChild(cancelButton); + buttonContainer.appendChild(deleteButton); + + // Insert at the beginning of the container + container.insertBefore(buttonContainer, container.firstChild); + } + + /** + * Deletes all points in the current selection + */ + async deleteSelectedPoints() { + if (!this.selectedPoints || this.selectedPoints.length === 0) { + showFlashMessage('warning', 'No points selected'); + return; + } + + const pointCount = this.selectedPoints.length; + const confirmed = confirm( + `⚠️ WARNING: This will permanently delete ${pointCount} point${pointCount > 1 ? 's' : ''} from your location history.\n\n` + + `This action cannot be undone!\n\n` + + `Are you sure you want to continue?` + ); + + if (!confirmed) return; + + try { + // Get point IDs from the selected points + // Debug: log the structure of selected points + console.log('Selected points sample:', this.selectedPoints[0]); + + // Points format: [lat, lng, ?, ?, timestamp, ?, id, country, ?] + // ID is at index 6 based on the marker array structure + const pointIds = this.selectedPoints + .map(point => point[6]) // ID is at index 6 + .filter(id => id != null && id !== ''); + + console.log('Point IDs to delete:', pointIds); + + if (pointIds.length === 0) { + showFlashMessage('error', 'No valid point IDs found'); + return; + } + + // Call the bulk delete API + const response = await fetch('/api/v1/points/bulk_destroy', { + method: 'DELETE', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}`, + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' + }, + body: JSON.stringify({ point_ids: pointIds }) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Response error:', response.status, errorText); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + console.log('Delete result:', result); + + // Check if any points were actually deleted + if (result.count === 0) { + showFlashMessage('warning', 'No points were deleted. They may have already been removed.'); + this.clearSelection(); + return; + } + + // Show success message + showFlashMessage('notice', `Successfully deleted ${result.count} point${result.count > 1 ? 's' : ''}`); + + // Remove deleted points from the map + pointIds.forEach(id => { + this.mapsController.removeMarker(id); + }); + + // Update the polylines layer + this.updatePolylinesAfterDeletion(); + + // Update heatmap with remaining markers + if (this.mapsController.heatmapLayer) { + this.mapsController.heatmapLayer.setLatLngs( + this.mapsController.markers.map(marker => [marker[0], marker[1], 0.2]) + ); + } + + // Update fog if enabled + if (this.mapsController.fogOverlay && this.mapsController.map.hasLayer(this.mapsController.fogOverlay)) { + this.mapsController.updateFog( + this.mapsController.markers, + this.mapsController.clearFogRadius, + this.mapsController.fogLineThreshold + ); + } + + // Clear selection + this.clearSelection(); + + } catch (error) { + console.error('Error deleting points:', error); + showFlashMessage('error', 'Failed to delete points. Please try again.'); + } + } + + /** + * Updates polylines layer after deletion (similar to single point deletion) + */ + updatePolylinesAfterDeletion() { + let wasPolyLayerVisible = false; + + // Check if polylines layer was visible + if (this.mapsController.polylinesLayer) { + if (this.mapsController.map.hasLayer(this.mapsController.polylinesLayer)) { + wasPolyLayerVisible = true; + } + this.mapsController.map.removeLayer(this.mapsController.polylinesLayer); + } + + // Create new polylines layer with updated markers + this.mapsController.polylinesLayer = createPolylinesLayer( + this.mapsController.markers, + this.mapsController.map, + this.mapsController.timezone, + this.mapsController.routeOpacity, + this.mapsController.userSettings, + this.mapsController.distanceUnit + ); + + // Re-add to map if it was visible, otherwise ensure it's removed + if (wasPolyLayerVisible) { + this.mapsController.polylinesLayer.addTo(this.mapsController.map); + } else { + this.mapsController.map.removeLayer(this.mapsController.polylinesLayer); + } + + // Update layer control + if (this.mapsController.layerControl) { + this.mapsController.map.removeControl(this.mapsController.layerControl); + const controlsLayer = { + Points: this.mapsController.markersLayer || L.layerGroup(), + Routes: this.mapsController.polylinesLayer || L.layerGroup(), + Tracks: this.mapsController.tracksLayer || L.layerGroup(), + Heatmap: this.mapsController.heatmapLayer || L.layerGroup(), + "Fog of War": this.mapsController.fogOverlay, + "Scratch map": this.mapsController.scratchLayerManager?.getLayer() || L.layerGroup(), + Areas: this.mapsController.areasLayer || L.layerGroup(), + Photos: this.mapsController.photoMarkers || L.layerGroup(), + "Suggested Visits": this.getVisitCirclesLayer(), + "Confirmed Visits": this.getConfirmedVisitCirclesLayer() + }; + + // Include Family Members layer if available + if (window.familyMembersController?.familyMarkersLayer) { + controlsLayer['Family Members'] = window.familyMembersController.familyMarkersLayer; + } + + this.mapsController.layerControl = L.control.layers( + this.mapsController.baseMaps(), + controlsLayer + ).addTo(this.mapsController.map); } } @@ -424,13 +624,9 @@ export class VisitsManager { drawerButton.innerHTML = this.drawerOpen ? '' : ''; } - const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button'); - controls.forEach(control => { - control.classList.toggle('controls-shifted'); - }); - // Update the drawer content if it's being opened - but don't fetch visits automatically - if (this.drawerOpen) { + // Only show the "no data" message if there's no selection active + if (this.drawerOpen && !this.isSelectionActive) { const container = document.getElementById('visits-list'); if (container) { container.innerHTML = ` @@ -451,16 +647,18 @@ export class VisitsManager { createDrawer() { const drawer = document.createElement('div'); drawer.id = 'visits-drawer'; - drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-39 overflow-y-auto leaflet-drawer'; + drawer.className = 'bg-base-100 shadow-lg z-39 overflow-y-auto leaflet-drawer'; // Add styles to make the drawer scrollable drawer.style.overflowY = 'auto'; - drawer.style.maxHeight = '100vh'; drawer.innerHTML = ` -
-

Recent Visits

-
+
+ +

Recent Visits

+

Loading visits...

@@ -472,6 +670,15 @@ export class VisitsManager { L.DomEvent.disableClickPropagation(drawer); this.map.getContainer().appendChild(drawer); + + // Add close button event listener + const closeButton = drawer.querySelector('#close-visits-drawer'); + if (closeButton) { + closeButton.addEventListener('click', () => { + this.toggleDrawer(); + }); + } + return drawer; } @@ -630,6 +837,10 @@ export class VisitsManager { return; } + // Save the current state of collapsible sections before updating + const dataSectionOpen = document.querySelector('#data-section-collapse')?.open || false; + const visitsSectionOpen = document.querySelector('#visits-section-collapse')?.open || false; + // Update the drawer title if selection is active if (this.isSelectionActive && this.selectionRect) { const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0; @@ -693,7 +904,7 @@ export class VisitsManager { const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : ''; return ` -
0 ? ` +
+ + Visits (${visits.filter(v => v.status !== 'declined').length}) + +
+ ${visitsHtml} +
+
+ ` : ''; + // Combine date summary and visits HTML - container.innerHTML = dateGroupsHtml + visitsHtml; + container.innerHTML = dateGroupsHtml + visitsSection; + + // Restore the state of collapsible sections + const dataSection = document.querySelector('#data-section-collapse'); + const visitsSection2 = document.querySelector('#visits-section-collapse'); + + if (dataSection && dataSectionOpen) { + dataSection.open = true; + } + if (visitsSection2 && visitsSectionOpen) { + visitsSection2.open = true; + } // Add the circles layer to the map this.visitCircles.addTo(this.map); diff --git a/app/jobs/family/invitations/cleanup_job.rb b/app/jobs/family/invitations/cleanup_job.rb index 2f00cdd0..a80ad443 100644 --- a/app/jobs/family/invitations/cleanup_job.rb +++ b/app/jobs/family/invitations/cleanup_job.rb @@ -13,9 +13,10 @@ class Family::Invitations::CleanupJob < ApplicationJob Rails.logger.info "Updated #{expired_count} expired family invitations" cleanup_threshold = 30.days.ago - deleted_count = Family::Invitation.where(status: [:expired, :cancelled]) - .where('updated_at < ?', cleanup_threshold) - .delete_all + deleted_count = + Family::Invitation.where(status: %i[expired cancelled]) + .where('updated_at < ?', cleanup_threshold) + .delete_all Rails.logger.info "Deleted #{deleted_count} old family invitations" diff --git a/app/jobs/family/invitations/sending_job.rb b/app/jobs/family/invitations/sending_job.rb new file mode 100644 index 00000000..da74dc52 --- /dev/null +++ b/app/jobs/family/invitations/sending_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Family::Invitations::SendingJob < ApplicationJob + queue_as :families + + def perform(invitation_id) + invitation = Family::Invitation.find_by(id: invitation_id) + + return unless invitation&.pending? + + FamilyMailer.invitation(invitation).deliver_now + end +end diff --git a/app/jobs/tracks/daily_generation_job.rb b/app/jobs/tracks/daily_generation_job.rb index ba149f8a..095199c1 100644 --- a/app/jobs/tracks/daily_generation_job.rb +++ b/app/jobs/tracks/daily_generation_job.rb @@ -38,8 +38,8 @@ class Tracks::DailyGenerationJob < ApplicationJob Tracks::ParallelGeneratorJob.perform_later( user.id, - start_at: start_timestamp, - end_at: Time.current.to_i, + start_at: Time.zone.at(start_timestamp), + end_at: Time.current, mode: 'daily' ) end diff --git a/app/models/family.rb b/app/models/family.rb index 51123293..4764fc66 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -11,6 +11,8 @@ class Family < ApplicationRecord MAX_MEMBERS = 5 def can_add_members? + return true if DawarichSettings.self_hosted? + (member_count + pending_invitations_count) < MAX_MEMBERS end @@ -32,6 +34,8 @@ class Family < ApplicationRecord end def full? + return false if DawarichSettings.self_hosted? + (member_count + pending_invitations_count) >= MAX_MEMBERS end diff --git a/app/models/import.rb b/app/models/import.rb index 4544819e..e69e8328 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -22,7 +22,7 @@ class Import < ApplicationRecord enum :source, { google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7, - user_data_archive: 8 + user_data_archive: 8, kml: 9 }, allow_nil: true def process! diff --git a/app/policies/family_invitation_policy.rb b/app/policies/family_invitation_policy.rb deleted file mode 100644 index 2369458b..00000000 --- a/app/policies/family_invitation_policy.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class FamilyInvitationPolicy < ApplicationPolicy - def show? - # Public endpoint for invitation acceptance - no authentication required - true - end - - def create? - user.family == record.family && user.family_owner? - end - - def accept? - # Users can accept invitations sent to their email - user.email == record.email - end - - def destroy? - # Only family owners can cancel invitations - user.family == record.family && user.family_owner? - end -end diff --git a/app/policies/family_membership_policy.rb b/app/policies/family_membership_policy.rb deleted file mode 100644 index 1b50c18e..00000000 --- a/app/policies/family_membership_policy.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class FamilyMembershipPolicy < ApplicationPolicy - def show? - user.family == record.family - end - - def update? - # Users can update their own settings - return true if user == record.user - - # Family owners can update any member's settings - user.family == record.family && user.family_owner? - end - - def destroy? - # Users can remove themselves (handled by family leave logic) - return true if user == record.user - - # Family owners can remove other members - user.family == record.family && user.family_owner? - end -end diff --git a/app/policies/family_policy.rb b/app/policies/family_policy.rb index b644de53..882aab13 100644 --- a/app/policies/family_policy.rb +++ b/app/policies/family_policy.rb @@ -34,6 +34,10 @@ class FamilyPolicy < ApplicationPolicy user.family == record && user.family_owner? end + def update_location_sharing? + user.family == record && user.family_owner? + end + private def family_owner_with_members? diff --git a/app/serializers/points/gpx_serializer.rb b/app/serializers/points/gpx_serializer.rb index fa088ecd..82eb156f 100644 --- a/app/serializers/points/gpx_serializer.rb +++ b/app/serializers/points/gpx_serializer.rb @@ -1,5 +1,17 @@ # frozen_string_literal: true +# Simple wrapper class that acts like GPX::GPXFile but preserves enhanced XML +class EnhancedGpxFile < GPX::GPXFile + def initialize(name, xml_string) + super(name: name) + @enhanced_xml = xml_string + end + + def to_s + @enhanced_xml + end +end + class Points::GpxSerializer def initialize(points, name) @points = points @@ -7,30 +19,92 @@ class Points::GpxSerializer end def call - gpx_file = GPX::GPXFile.new(name: "dawarich_#{name}") - track = GPX::Track.new(name: "dawarich_#{name}") + gpx_file = create_base_gpx_file + add_track_points_to_gpx(gpx_file) + xml_string = enhance_gpx_with_speed_and_course(gpx_file.to_s) - gpx_file.tracks << track - - track_segment = GPX::Segment.new - track.segments << track_segment - - points.each do |point| - track_segment.points << GPX::TrackPoint.new( - lat: point.lat, - lon: point.lon, - elevation: point.altitude.to_f, - time: point.recorded_at - ) - end - - GPX::GPXFile.new( - name: "dawarich_#{name}", - gpx_data: gpx_file.to_s.sub(']*>.*?<\/trkpt>)/m) do |trkpt_xml| + point = points[trkpt_count] + trkpt_count += 1 + enhance_single_trackpoint(trkpt_xml, point) + end + end + + def enhance_single_trackpoint(trkpt_xml, point) + enhanced_trkpt = add_speed_to_trackpoint(trkpt_xml, point) + add_course_to_trackpoint(enhanced_trkpt, point) + end + + def add_speed_to_trackpoint(trkpt_xml, point) + return trkpt_xml unless should_include_speed?(point) + + trkpt_xml.sub(/([^<]*<\/ele>)/, "\\1\n #{point.velocity.to_f}") + end + + def add_course_to_trackpoint(trkpt_xml, point) + return trkpt_xml unless should_include_course?(point) + + extensions_xml = "\n \n #{point.course.to_f}\n " + trkpt_xml.sub(/\n <\/trkpt>/, "#{extensions_xml}\n ") + end + + def should_include_speed?(point) + point.velocity.present? && point.velocity.to_f > 0 + end + + def should_include_course?(point) + point.course.present? + end end diff --git a/app/services/families/invite.rb b/app/services/families/invite.rb index c1d7796b..15ccaf89 100644 --- a/app/services/families/invite.rb +++ b/app/services/families/invite.rb @@ -19,8 +19,8 @@ module Families return false unless invite_sendable? ActiveRecord::Base.transaction do - create_invitation - send_invitation_email + invitation = create_invitation + send_invitation_email(invitation) send_notification end @@ -80,16 +80,18 @@ module Families ) end - def send_invitation_email - # Send email in background with retry logic - FamilyMailer.invitation(@invitation).deliver_later( - queue: :mailer, - retry: 3, - wait: 30.seconds - ) + def send_invitation_email(invitation) + Family::Invitations::SendingJob.perform_later(invitation.id) end def send_notification + message = + if DawarichSettings.self_hosted? + "Family invitation sent to #{email} if SMTP is configured properly. If you're not using SMTP, copy the invitation link from the family page and share it manually." + else + "Family invitation sent to #{email}" + end + Notification.create!( user: invited_by, kind: :info, diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb index d920f374..20ceae11 100644 --- a/app/services/imports/create.rb +++ b/app/services/imports/create.rb @@ -58,6 +58,7 @@ class Imports::Create when 'google_records' then GoogleMaps::RecordsStorageImporter when 'owntracks' then OwnTracks::Importer when 'gpx' then Gpx::TrackImporter + when 'kml' then Kml::Importer when 'geojson' then Geojson::Importer when 'immich_api', 'photoprism_api' then Photos::Importer else diff --git a/app/services/imports/source_detector.rb b/app/services/imports/source_detector.rb index a1bfd004..d5b0a2c6 100644 --- a/app/services/imports/source_detector.rb +++ b/app/services/imports/source_detector.rb @@ -71,6 +71,7 @@ class Imports::SourceDetector def detect_source return :gpx if gpx_file? + return :kml if kml_file? return :owntracks if owntracks_file? json_data = parse_json @@ -116,6 +117,22 @@ class Imports::SourceDetector ) && content_to_check.include?(' e + Rails.logger.warn("Failed to parse gx:Track point at index #{i}: #{e.message}") + next + end + end + + points + end + + def parse_coordinates(coord_text) + # KML coordinates format: "longitude,latitude[,altitude] ..." + # Multiple coordinates separated by whitespace + return [] if coord_text.blank? + + coord_text.strip.split(/\s+/).map do |coord_str| + parts = coord_str.split(',') + next if parts.size < 2 + + { + lng: parts[0].to_f, + lat: parts[1].to_f, + alt: parts[2]&.to_f || 0.0 + } + end.compact + end + + def extract_timestamp(placemark) + # Try TimeStamp first + timestamp_node = REXML::XPath.first(placemark, './/TimeStamp/when') + return Time.parse(timestamp_node.text).to_i if timestamp_node + + # Try TimeSpan begin + timespan_begin = REXML::XPath.first(placemark, './/TimeSpan/begin') + return Time.parse(timespan_begin.text).to_i if timespan_begin + + # Try TimeSpan end as fallback + timespan_end = REXML::XPath.first(placemark, './/TimeSpan/end') + return Time.parse(timespan_end.text).to_i if timespan_end + + # Default to import creation time if no timestamp found + import.created_at.to_i + rescue StandardError => e + Rails.logger.warn("Failed to parse timestamp: #{e.message}") + import.created_at.to_i + end + + def build_point(coord, timestamp, placemark) + return if coord[:lat].blank? || coord[:lng].blank? + + { + lonlat: "POINT(#{coord[:lng]} #{coord[:lat]})", + altitude: coord[:alt].to_i, + timestamp: timestamp, + import_id: import.id, + velocity: extract_velocity(placemark), + raw_data: extract_extended_data(placemark), + user_id: user_id, + created_at: Time.current, + updated_at: Time.current + } + end + + def extract_velocity(placemark) + # Try to extract speed from ExtendedData + speed_node = REXML::XPath.first(placemark, ".//Data[@name='speed']/value") || + REXML::XPath.first(placemark, ".//Data[@name='Speed']/value") || + REXML::XPath.first(placemark, ".//Data[@name='velocity']/value") + + return speed_node.text.to_f.round(1) if speed_node + + 0.0 + rescue StandardError + 0.0 + end + + def extract_extended_data(placemark) + data = {} + + # Extract name if present + name_node = REXML::XPath.first(placemark, './/name') + data['name'] = name_node.text.strip if name_node + + # Extract description if present + desc_node = REXML::XPath.first(placemark, './/description') + data['description'] = desc_node.text.strip if desc_node + + # Extract all ExtendedData/Data elements + REXML::XPath.each(placemark, './/ExtendedData/Data') do |data_node| + name = data_node.attributes['name'] + value_node = REXML::XPath.first(data_node, './value') + data[name] = value_node.text if name && value_node + end + + data + rescue StandardError => e + Rails.logger.warn("Failed to extract extended data: #{e.message}") + {} + end + + def bulk_insert_points(batch) + unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + + # rubocop:disable Rails/SkipsModelValidations + Point.upsert_all( + unique_batch, + unique_by: %i[lonlat timestamp user_id], + returning: false, + on_duplicate: :skip + ) + # rubocop:enable Rails/SkipsModelValidations + + broadcast_import_progress(import, unique_batch.size) + rescue StandardError => e + create_notification("Failed to process KML file: #{e.message}") + end + + def create_notification(message) + Notification.create!( + user_id: user_id, + title: 'KML Import Error', + content: message, + kind: :error + ) + end +end diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index d5c4c1ce..52ad05db 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -17,6 +17,8 @@
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', method: :put, data: { turbo_method: :put, turbo: false }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> +
<%= f.label :email, class: 'label' do %> Email diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 54780f26..d367458b 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -16,12 +16,23 @@
<% else %> -

Register now!

-

and take control over your location data.

+

Almost there!

<% end %> +

+ Only a few steps left until you get control over your location data! +

+
    +
  1. 1. Create your account
  2. +
  3. 2. Configure your mobile app
  4. +
  5. 3. Start tracking your location data securely
  6. +
  7. 4. ...
  8. +
  9. 5. You're beautiful!
  10. +
+
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> <% if @invitation %> <%= f.hidden_field :invitation_token, value: params[:invitation_token] %> <% end %> @@ -32,7 +43,7 @@ <% end %> <%= f.email_field :email, autofocus: true, autocomplete: "email", readonly: @invitation.present?, - class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %> + class: "input input-bordered w-full #{@invitation ? 'input-disabled' : ''}" %>
@@ -42,7 +53,7 @@ <% if @minimum_password_length %> (<%= @minimum_password_length %> characters minimum) <% end %>
- <%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %> + <%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered w-full' %>
@@ -52,7 +63,7 @@ <% if @minimum_password_length %> (<%= @minimum_password_length %> characters minimum) <% end %>
- <%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered w-full' %>
<% if !DawarichSettings.self_hosted? %> diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index 0de3bae8..a12c108c 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -20,6 +20,8 @@
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> + <% if @invitation %> <%= hidden_field_tag :invitation_token, params[:invitation_token] %> <% end %> diff --git a/app/views/devise/shared/_error_messages.html.erb b/app/views/devise/shared/_error_messages.html.erb index cabfe307..2b2b4cfe 100644 --- a/app/views/devise/shared/_error_messages.html.erb +++ b/app/views/devise/shared/_error_messages.html.erb @@ -1,15 +1,20 @@ <% if resource.errors.any? %> -
-

- <%= I18n.t("errors.messages.not_saved", - count: resource.errors.count, - resource: resource.class.model_name.human.downcase) - %> -

-
    - <% resource.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
+
+ <%= icon 'circle-x' %> +
+
+

+ <%= I18n.t("errors.messages.not_saved", + count: resource.errors.count, + resource: resource.class.model_name.human.downcase) + %> +

+
    + <% resource.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
<% end %> diff --git a/app/views/families/edit.html.erb b/app/views/families/edit.html.erb index 7007d4a3..513767a8 100644 --- a/app/views/families/edit.html.erb +++ b/app/views/families/edit.html.erb @@ -88,7 +88,7 @@ <% if policy(@family).destroy? %> <%= link_to family_path, method: :delete, - data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, + data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_method: :delete }, class: "btn btn-outline btn-error" do %> <%= icon 'trash-2', class: "inline-block w-4" %> Delete Family diff --git a/app/views/families/show.html.erb b/app/views/families/show.html.erb index 6b55938b..cd7a074c 100644 --- a/app/views/families/show.html.erb +++ b/app/views/families/show.html.erb @@ -26,7 +26,7 @@ <% if !current_user.family_owner? && current_user.family_membership %> <%= link_to family_member_path(current_user.family_membership), method: :delete, - data: { turbo_confirm: 'Are you sure you want to leave this family?' }, + data: { turbo_confirm: 'Are you sure you want to leave this family?', turbo_method: :delete }, class: "btn btn-outline btm-sm btn-warning" do %> Leave Family <% end %> @@ -35,7 +35,7 @@ <% if policy(@family).destroy? %> <%= link_to family_path, method: :delete, - data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, + data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_method: :delete }, class: "btn btn-outline btm-sm btn-error" do %> <%= icon 'trash-2', class: "inline-block w-4" %> Delete @@ -175,38 +175,46 @@ <% if @pending_invitations.any? %>
<% @pending_invitations.each do |invitation| %> -
-
-
<%= invitation.email %>
-
- <%= t('families.show.invited_on', default: 'Invited') %> - <%= invitation.created_at.strftime('%b %d, %Y') %> -
-
- <%= t('families.show.expires_on', default: 'Expires') %> - <%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %> -
-
- +
+
+
+
<%= invitation.email %>
+
+ <%= t('families.show.invited_on', default: 'Invited') %> + <%= invitation.created_at.strftime('%b %d, %Y') %> +
+
+ <%= t('families.show.expires_on', default: 'Expires') %> + <%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %> +
+ <% if policy(@family).manage_invitations? %> +
+ <%= button_to family_invitation_path(invitation.token), + method: :delete, + form: { data: { turbo_confirm: 'Are you sure you want to cancel this invitation?', turbo_method: :delete } }, + class: "btn btn-outline btn-warning btn-sm" do %> + Cancel + <% end %> +
+ <% end %> +
+
+ +
- <% if policy(@family).manage_invitations? %> -
- <%= link_to family_invitation_path(invitation.token), - method: :delete, - data: { turbo_confirm: 'Are you sure you want to cancel this invitation?' }, - class: "btn btn-outline btn-warning btn-sm" do %> - Cancel - <% end %> -
- <% end %>
<% end %>
diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb index 16fd3cd5..9b928b0d 100644 --- a/app/views/imports/_form.html.erb +++ b/app/views/imports/_form.html.erb @@ -7,6 +7,7 @@
  • ✅ GPX: Track files (.gpx)
  • ✅ GeoJSON: Feature collections (.json)
  • ✅ OwnTracks: Recorder files (.rec)
  • +
  • ✅ KML: KML files (.kml)
  • File format is automatically detected during upload. diff --git a/app/views/layouts/map.html.erb b/app/views/layouts/map.html.erb index c53260c2..99156350 100644 --- a/app/views/layouts/map.html.erb +++ b/app/views/layouts/map.html.erb @@ -38,7 +38,7 @@
    -
    +
    <%= yield %>
    diff --git a/app/views/map/_settings_modals.html.erb b/app/views/map/_settings_modals.html.erb index 31bb3d12..7ed0a306 100644 --- a/app/views/map/_settings_modals.html.erb +++ b/app/views/map/_settings_modals.html.erb @@ -184,7 +184,7 @@ Here you can set a custom color scale for speed colored routes. It uses color stops at specified km/h values and creates a gradient from it. The default value is 0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300

    - You can also use the 'Edit Scale' button to edit it using an UI. + You can also use the 'Edit Colors' button to edit it using an UI.

    diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 40a58ec0..ce90c478 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -1,10 +1,9 @@ <% content_for :title, 'Map' %> - -
    -
    + +
    -
    +
    - -
    -
    -
    + +
    +
    +
    +
    +
    diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 1be2eef2..0f6f700f 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -156,8 +156,7 @@
  • - - <%= icon 'user' %> + <%= icon 'user' %> <% if onboarding_modal_showable?(current_user) %> <% end %> diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 7b562e40..2e9b40eb 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -18,6 +18,8 @@ <% if DawarichSettings.reverse_geocoding_enabled? %> <%= render 'stats/reverse_geocoding_stats' %> + <% else %> +
  • <% end %>
    diff --git a/config/cable.yml b/config/cable.yml index e5713aea..6b112408 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,6 +1,7 @@ default: &default adapter: redis - url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %> + url: <%= "#{ENV.fetch("REDIS_URL", "redis://localhost:6379")}" %> + db: <%= ENV.fetch('RAILS_WS_DB', 2) %> development: <<: *default diff --git a/config/environments/development.rb b/config/environments/development.rb index c940de0e..0e950d8c 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -26,7 +26,10 @@ Rails.application.configure do # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. - config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" } + config.cache_store = :redis_cache_store, { + url: ENV['REDIS_URL'], + db: ENV.fetch('RAILS_CACHE_DB', 0) + } if Rails.root.join('tmp/caching-dev.txt').exist? config.action_controller.perform_caching = true @@ -86,7 +89,7 @@ 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 - hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',') + hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip) config.action_mailer.default_url_options = { host: ENV['DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) } @@ -99,5 +102,5 @@ Rails.application.configure do config.lograge.enabled = true config.lograge.formatter = Lograge::Formatters::Json.new - config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3 + config.active_storage.service = ENV.fetch('STORAGE_BACKEND', :local) end diff --git a/config/environments/production.rb b/config/environments/production.rb index 1e4b392a..8dd9762f 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -43,7 +43,7 @@ Rails.application.configure do # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX # Store uploaded files on the local file system (see config/storage.yml for options). - config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3 + config.active_storage.service = ENV.fetch('STORAGE_BACKEND', :local) config.silence_healthcheck_path = '/api/v1/health' @@ -73,7 +73,10 @@ Rails.application.configure do config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info') # Use a different cache store in production. - config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" } + config.cache_store = :redis_cache_store, { + url: ENV['REDIS_URL'], + db: ENV.fetch('RAILS_CACHE_DB', 0) + } # Use a real queuing backend for Active Job (and separate queues per environment). config.active_job.queue_adapter = :sidekiq @@ -101,7 +104,7 @@ Rails.application.configure do # ] # Skip DNS rebinding protection for the health check endpoint. config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } } - hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',') + hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip) config.action_mailer.default_url_options = { host: ENV['DOMAIN'] } config.hosts.concat(hosts) if hosts.present? diff --git a/config/environments/staging.rb b/config/environments/staging.rb index ded741dc..cd248041 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -101,7 +101,7 @@ Rails.application.configure do # ] # Skip DNS rebinding protection for the health check endpoint. config.host_authorization = { exclude: ->(request) { request.path == '/api/v1/health' } } - hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',') + hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip) config.action_mailer.default_url_options = { host: ENV['DOMAIN'] } config.hosts.concat(hosts) if hosts.present? diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb index 13a8bbdd..c7238e7a 100644 --- a/config/initializers/geocoder.rb +++ b/config/initializers/geocoder.rb @@ -4,7 +4,7 @@ settings = { debug_mode: true, timeout: 5, units: :km, - cache: Redis.new(url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}"), + cache: Redis.new(url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_CACHE_DB', 0)), always_raise: :all, http_headers: { 'User-Agent' => "Dawarich #{APP_VERSION} (https://dawarich.app)" diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 323655e3..36994f83 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true Sidekiq.configure_server do |config| - config.redis = { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_JOB_QUEUE_DB', 1)}" } + config.redis = { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_JOB_QUEUE_DB', 1) } config.logger = Sidekiq::Logger.new($stdout) if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true' diff --git a/config/initializers/strong_migrations.rb b/config/initializers/strong_migrations.rb index ec978211..78012899 100644 --- a/config/initializers/strong_migrations.rb +++ b/config/initializers/strong_migrations.rb @@ -1,26 +1,31 @@ -# Mark existing migrations as safe -StrongMigrations.start_after = 20_250_122_150_500 +# frozen_string_literal: true -# Set timeouts for migrations -# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user -StrongMigrations.lock_timeout = 10.seconds -StrongMigrations.statement_timeout = 1.hour +# return unless Rails.env.development? -# Analyze tables after indexes are added -# Outdated statistics can sometimes hurt performance -StrongMigrations.auto_analyze = true +# # Mark existing migrations as safe +# StrongMigrations.start_after = 20_250_122_150_500 -# Set the version of the production database -# so the right checks are run in development -# StrongMigrations.target_version = 10 +# # Set timeouts for migrations +# # PgBouncer in transaction mode doesn't support SET commands +# # Timeouts should be set on the database user instead +# # StrongMigrations.lock_timeout = 10.seconds +# # StrongMigrations.statement_timeout = 1.hour -# Add custom checks -# StrongMigrations.add_check do |method, args| -# if method == :add_index && args[0].to_s == "users" -# stop! "No more indexes on the users table" -# end -# end +# # Analyze tables after indexes are added +# # Outdated statistics can sometimes hurt performance +# StrongMigrations.auto_analyze = true -# Make some operations safe by default -# See https://github.com/ankane/strong_migrations#safe-by-default -# StrongMigrations.safe_by_default = true +# # Set the version of the production database +# # so the right checks are run in development +# # StrongMigrations.target_version = 10 + +# # Add custom checks +# # StrongMigrations.add_check do |method, args| +# # if method == :add_index && args[0].to_s == "users" +# # stop! "No more indexes on the users table" +# # end +# # end + +# # Make some operations safe by default +# # See https://github.com/ankane/strong_migrations#safe-by-default +# # StrongMigrations.safe_by_default = true diff --git a/config/routes.rb b/config/routes.rb index dfbcee4c..bd650203 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -126,7 +126,11 @@ Rails.application.routes.draw do get 'suggestions' end end - resources :points, only: %i[index create update destroy] + resources :points, only: %i[index create update destroy] do + collection do + delete :bulk_destroy + end + end resources :visits, only: %i[index create update destroy] do get 'possible_places', to: 'visits/possible_places#index', on: :member collection do diff --git a/config/storage.yml b/config/storage.yml index 0d9a1fec..78b402ab 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -7,13 +7,14 @@ local: root: <%= Rails.root.join("storage") %> # Only load S3 config if not in test environment -<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] %> +<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] && ENV['AWS_ENDPOINT_URL'] %> s3: service: S3 access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID") %> secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY") %> region: <%= ENV.fetch("AWS_REGION") %> bucket: <%= ENV.fetch("AWS_BUCKET") %> + endpoint: <%= ENV.fetch("AWS_ENDPOINT_URL") %> <% end %> # Remember not to checkin your GCS keyfile to a repository diff --git a/db/migrate/20250513164521_add_visited_countries_to_trips.rb b/db/migrate/20250513164521_add_visited_countries_to_trips.rb index fd9d7a4e..27797428 100644 --- a/db/migrate/20250513164521_add_visited_countries_to_trips.rb +++ b/db/migrate/20250513164521_add_visited_countries_to_trips.rb @@ -2,10 +2,10 @@ class AddVisitedCountriesToTrips < ActiveRecord::Migration[8.0] def change - safety_assured do + # safety_assured do execute <<-SQL ALTER TABLE trips ADD COLUMN visited_countries JSONB DEFAULT '{}'::jsonb NOT NULL; SQL - end + # end end end diff --git a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb index cdb627e9..fbb19073 100644 --- a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb +++ b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb @@ -5,10 +5,10 @@ class AddH3HexIdsToStats < ActiveRecord::Migration[8.0] def change add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true - safety_assured do + # safety_assured do add_index :stats, :h3_hex_ids, using: :gin, where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)", algorithm: :concurrently, if_not_exists: true - end + # end end end diff --git a/db/migrate/20250926220114_create_families.rb b/db/migrate/20250926220114_create_families.rb index cbaeaf25..72098b9f 100644 --- a/db/migrate/20250926220114_create_families.rb +++ b/db/migrate/20250926220114_create_families.rb @@ -8,7 +8,7 @@ class CreateFamilies < ActiveRecord::Migration[8.0] t.timestamps end - add_foreign_key :families, :users, column: :creator_id, validate: false + add_foreign_key :families, :users, column: :creator_id add_index :families, :creator_id end end diff --git a/db/migrate/20250926220135_create_family_memberships.rb b/db/migrate/20250926220135_create_family_memberships.rb index fa8e051a..90f53947 100644 --- a/db/migrate/20250926220135_create_family_memberships.rb +++ b/db/migrate/20250926220135_create_family_memberships.rb @@ -9,8 +9,8 @@ class CreateFamilyMemberships < ActiveRecord::Migration[8.0] t.timestamps end - add_foreign_key :family_memberships, :families, validate: false - add_foreign_key :family_memberships, :users, validate: false + add_foreign_key :family_memberships, :families + add_foreign_key :family_memberships, :users add_index :family_memberships, :user_id, unique: true # One family per user add_index :family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role' end diff --git a/db/migrate/20250926220158_create_family_invitations.rb b/db/migrate/20250926220158_create_family_invitations.rb index be841652..b9279a5f 100644 --- a/db/migrate/20250926220158_create_family_invitations.rb +++ b/db/migrate/20250926220158_create_family_invitations.rb @@ -12,8 +12,8 @@ class CreateFamilyInvitations < ActiveRecord::Migration[8.0] t.timestamps end - add_foreign_key :family_invitations, :families, validate: false - add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false + add_foreign_key :family_invitations, :families + add_foreign_key :family_invitations, :users, column: :invited_by_id add_index :family_invitations, :token, unique: true add_index :family_invitations, %i[family_id email], name: 'index_family_invitations_on_family_id_and_email' add_index :family_invitations, %i[family_id status expires_at], diff --git a/db/migrate/20250926220345_validate_family_foreign_keys.rb b/db/migrate/20250926220345_validate_family_foreign_keys.rb index 45461b79..d0161227 100644 --- a/db/migrate/20250926220345_validate_family_foreign_keys.rb +++ b/db/migrate/20250926220345_validate_family_foreign_keys.rb @@ -1,9 +1,10 @@ class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0] def change - validate_foreign_key :families, :users - validate_foreign_key :family_memberships, :families - validate_foreign_key :family_memberships, :users - validate_foreign_key :family_invitations, :families - validate_foreign_key :family_invitations, :users + # No longer needed - foreign keys are now validated immediately in their creation migrations + # validate_foreign_key :families, :users + # validate_foreign_key :family_memberships, :families + # validate_foreign_key :family_memberships, :users + # validate_foreign_key :family_invitations, :families + # validate_foreign_key :family_invitations, :users end end diff --git a/db/migrate/20251030190924_add_utm_parameters_to_users.rb b/db/migrate/20251030190924_add_utm_parameters_to_users.rb new file mode 100644 index 00000000..1df48ce6 --- /dev/null +++ b/db/migrate/20251030190924_add_utm_parameters_to_users.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddUtmParametersToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :utm_source, :string + add_column :users, :utm_medium, :string + add_column :users, :utm_campaign, :string + add_column :users, :utm_term, :string + add_column :users, :utm_content, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 1a9e1ae4..99e437d8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_28_160950) do +ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -320,6 +320,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_28_160950) do t.text "patreon_access_token" t.text "patreon_refresh_token" t.datetime "patreon_token_expires_at" + t.string "utm_source" + t.string "utm_medium" + t.string "utm_campaign" + t.string "utm_term" + t.string "utm_content" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..008b2af6 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,141 @@ +# Dawarich Docker Compose Configuration +# Copy this file to .env and customize for your environment + +# ============================================================================= +# ENVIRONMENT CONFIGURATION +# ============================================================================= + +# Rails environment: development, staging, or production +RAILS_ENV=development + +# ============================================================================= +# DATABASE CONFIGURATION +# ============================================================================= + +# PostgreSQL credentials +POSTGRES_USER=postgres +POSTGRES_PASSWORD=password + +# Database name +POSTGRES_DB=dawarich_development + +# Database connection settings (used by Rails app) +DATABASE_HOST=dawarich_db +DATABASE_PORT=5432 +DATABASE_USERNAME=postgres +DATABASE_PASSWORD=password +DATABASE_NAME=dawarich_development + +# ============================================================================= +# REDIS CONFIGURATION +# ============================================================================= + +# Redis connection URL +REDIS_URL=redis://dawarich_redis:6379 + +# ============================================================================= +# APPLICATION SETTINGS +# ============================================================================= + +# Port to expose the application on +DAWARICH_APP_PORT=3000 + +# Application hosts (comma-separated) +# Development: localhost +# Production: your-domain.com,www.your-domain.com +APPLICATION_HOSTS=localhost,::1,127.0.0.1 + +# Application protocol (http or https) +APPLICATION_PROTOCOL=http + +# Time zone +TIME_ZONE=Europe/London + +# Minimum minutes spent in city for statistics +MIN_MINUTES_SPENT_IN_CITY=60 + +# Self-hosted flag (true for docker deployments) +SELF_HOSTED=true + +# Store geodata (reverse geocoding results) +STORE_GEODATA=true + +# Storage backend (local or s3) +STORAGE_BACKEND=local + +# ============================================================================= +# SECURITY +# ============================================================================= + +# Secret key base for production/staging +# Generate with: openssl rand -hex 64 +# Leave empty for development +# REQUIRED for production and staging environments +SECRET_KEY_BASE= + +# ============================================================================= +# BACKGROUND JOBS +# ============================================================================= + +# Sidekiq concurrency (number of threads) +BACKGROUND_PROCESSING_CONCURRENCY=10 + +# ============================================================================= +# MONITORING & LOGGING +# ============================================================================= + +# Prometheus exporter settings +PROMETHEUS_EXPORTER_ENABLED=false +PROMETHEUS_EXPORTER_HOST=0.0.0.0 +PROMETHEUS_EXPORTER_PORT=9394 +PROMETHEUS_EXPORTER_HOST_SIDEKIQ=dawarich_app + +# Uncomment to expose Prometheus port +# PROMETHEUS_PORT=9394 + +# Rails logging +RAILS_LOG_TO_STDOUT=true + +# Docker logging settings +LOG_MAX_SIZE=100m +LOG_MAX_FILE=5 + +# ============================================================================= +# RESOURCE LIMITS +# ============================================================================= + +# CPU and memory limits for the app container +APP_CPU_LIMIT=0.50 +APP_MEMORY_LIMIT=4G + +# ============================================================================= +# EXAMPLE CONFIGURATIONS BY ENVIRONMENT +# ============================================================================= + +# --- DEVELOPMENT --- +# RAILS_ENV=development +# POSTGRES_DB=dawarich_development +# DATABASE_NAME=dawarich_development +# APPLICATION_HOSTS=localhost,::1,127.0.0.1 +# APPLICATION_PROTOCOL=http +# SECRET_KEY_BASE= +# SELF_HOSTED=true + +# --- STAGING --- +# RAILS_ENV=staging +# POSTGRES_DB=dawarich_staging +# DATABASE_NAME=dawarich_staging +# APPLICATION_HOSTS=staging.example.com +# APPLICATION_PROTOCOL=https +# SECRET_KEY_BASE=your-generated-secret-key +# SELF_HOSTED=true + +# --- PRODUCTION --- +# RAILS_ENV=production +# POSTGRES_DB=dawarich_production +# DATABASE_NAME=dawarich_production +# APPLICATION_HOSTS=dawarich.example.com,www.dawarich.example.com +# APPLICATION_PROTOCOL=https +# SECRET_KEY_BASE=your-generated-secret-key +# SELF_HOSTED=true +# PROMETHEUS_EXPORTER_ENABLED=true diff --git a/docker/Dockerfile.prod b/docker/Dockerfile similarity index 86% rename from docker/Dockerfile.prod rename to docker/Dockerfile index 29279c81..f9a6c3c0 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile @@ -1,11 +1,12 @@ FROM ruby:3.4.6-slim +ARG RAILS_ENV=production + 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 RUN apt-get update -qq \ && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \ @@ -25,12 +26,16 @@ RUN apt-get update -qq \ less \ libjemalloc2 libjemalloc-dev \ cmake \ - && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ - && apt-get install -y nodejs \ - && npm install -g yarn \ + ca-certificates \ && mkdir -p $APP_PATH \ && rm -rf /var/lib/apt/lists/* +# Install Node.js from Debian repositories (supports all architectures including armv7) +RUN apt-get update -qq \ + && apt-get install -y nodejs npm \ + && npm install -g yarn \ + && rm -rf /var/lib/apt/lists/* + # Use jemalloc with check for architecture RUN if [ "$(uname -m)" = "x86_64" ]; then \ echo "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \ @@ -41,7 +46,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \ # Enable YJIT ENV RUBY_YJIT_ENABLE=1 -# Update gem system and install bundler +# Update RubyGems and install Bundler RUN gem update --system 3.6.9 \ && gem install bundler --version "$BUNDLE_VERSION" \ && rm -rf $GEM_HOME/cache/* @@ -58,7 +63,7 @@ RUN bundle config set --local path 'vendor/bundle' \ COPY ../. ./ -# Precompile assets for production +# Precompile assets RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rake assets:precompile \ && rm -rf node_modules tmp/cache diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev deleted file mode 100644 index 77553008..00000000 --- a/docker/Dockerfile.dev +++ /dev/null @@ -1,87 +0,0 @@ -FROM ruby:3.4.6-slim - -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 -ENV SELF_HOSTED=true -ENV SIDEKIQ_USERNAME=sidekiq -ENV SIDEKIQ_PASSWORD=password -# Resolving sqlite3 error -ENV PGSSENCMODE=disable - -RUN apt-get update -qq \ - && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - curl \ - wget \ - build-essential \ - git \ - postgresql-client \ - libpq-dev \ - libxml2-dev \ - libxslt-dev \ - libyaml-dev \ - libgeos-dev libgeos++-dev \ - imagemagick \ - tzdata \ - less \ - libjemalloc2 libjemalloc-dev \ - cmake \ - ca-certificates \ - && mkdir -p $APP_PATH \ - && rm -rf /var/lib/apt/lists/* - -# Install Node.js using official NodeSource script -# NodeSource supports: amd64, arm64, armhf (arm/v7) -# For unsupported architectures, fall back to Debian's nodejs package -RUN ARCH=$(dpkg --print-architecture) && \ - if [ "$ARCH" = "amd64" ] || [ "$ARCH" = "arm64" ] || [ "$ARCH" = "armhf" ]; then \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y nodejs; \ - else \ - apt-get update && \ - apt-get install -y nodejs npm; \ - fi && \ - npm install -g yarn && \ - rm -rf /var/lib/apt/lists/* - -# Use jemalloc with check for architecture -RUN if [ "$(uname -m)" = "x86_64" ]; then \ - echo "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \ - else \ - echo "/usr/lib/aarch64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \ - fi - -# Optional: Set YJIT explicitly (enabled by default in 3.4.1 MRI builds) -ENV RUBY_YJIT_ENABLE=1 - -# Update RubyGems and install Bundler -RUN gem update --system 3.6.9 \ - && gem install bundler --version "$BUNDLE_VERSION" \ - && rm -rf $GEM_HOME/cache/* - -WORKDIR $APP_PATH - -COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ - -RUN bundle config set --local path 'vendor/bundle' \ - && bundle install --jobs 4 --retry 3 \ - && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem - -COPY ../. ./ - -# Create caching-dev.txt file to enable Rails caching in development -RUN mkdir -p $APP_PATH/tmp && touch $APP_PATH/tmp/caching-dev.txt - -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 deleted file mode 100644 index 608a916e..00000000 --- a/docker/docker-compose.production.yml +++ /dev/null @@ -1,154 +0,0 @@ -networks: - dawarich: -services: - dawarich_redis: - image: redis:7.4-alpine - container_name: dawarich_redis - command: redis-server - networks: - - dawarich - volumes: - - dawarich_redis_data:/data - restart: always - healthcheck: - test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ] - interval: 10s - retries: 5 - start_period: 30s - timeout: 10s - dawarich_db: - image: postgis/postgis:17-3.5-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 - - dawarich_storage:/var/app/storage - - dawarich_db_data:/dawarich_db_data - 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 - 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 - PROMETHEUS_EXPORTER_ENABLED: false - PROMETHEUS_EXPORTER_HOST: 0.0.0.0 - PROMETHEUS_EXPORTER_PORT: 9394 - SECRET_KEY_BASE: 1234567890 - RAILS_LOG_TO_STDOUT: "true" - STORE_GEODATA: "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 - - dawarich_storage:/var/app/storage - 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 - 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 - PROMETHEUS_EXPORTER_ENABLED: false - PROMETHEUS_EXPORTER_HOST: dawarich_app - PROMETHEUS_EXPORTER_PORT: 9394 - SECRET_KEY_BASE: 1234567890 - RAILS_LOG_TO_STDOUT: "true" - STORE_GEODATA: "true" - logging: - driver: "json-file" - options: - max-size: "100m" - max-file: "5" - healthcheck: - test: [ "CMD-SHELL", "pgrep -f sidekiq" ] - 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 -volumes: - dawarich_db_data: - dawarich_redis_data: - dawarich_public: - dawarich_watched: - dawarich_storage: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ca0fb27c..8af298f0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,5 +1,6 @@ networks: dawarich: + services: dawarich_redis: image: redis:7.4-alpine @@ -16,28 +17,30 @@ services: retries: 5 start_period: 30s timeout: 10s + dawarich_db: image: postgis/postgis:17-3.5-alpine + # image: imresamu/postgis:17-3.5-alpine # If you're on ARM architecture, use this image instead shm_size: 1G container_name: dawarich_db volumes: - dawarich_db_data:/var/lib/postgresql/data - dawarich_shared:/var/shared - # - ./postgresql.conf:/etc/postgresql/postgresql.conf # Optional, uncomment if you want to use a custom config networks: - dawarich environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: dawarich_development + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: ${POSTGRES_DB:-dawarich_development} restart: always healthcheck: - test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ] + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-dawarich_development}" ] interval: 10s retries: 5 start_period: 30s timeout: 10s # command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config + dawarich_app: image: freikin/dawarich:latest container_name: dawarich_app @@ -49,34 +52,37 @@ services: networks: - dawarich ports: - - 3000:3000 - # - 9394:9394 # Prometheus exporter, uncomment if needed + - "${DAWARICH_APP_PORT:-3000}:3000" + # - "${PROMETHEUS_PORT:-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: development - REDIS_URL: redis://dawarich_redis:6379 - DATABASE_HOST: dawarich_db - DATABASE_USERNAME: postgres - DATABASE_PASSWORD: password - DATABASE_NAME: dawarich_development - MIN_MINUTES_SPENT_IN_CITY: 60 - APPLICATION_HOSTS: localhost - TIME_ZONE: Europe/London - APPLICATION_PROTOCOL: http - PROMETHEUS_EXPORTER_ENABLED: "false" - PROMETHEUS_EXPORTER_HOST: 0.0.0.0 - PROMETHEUS_EXPORTER_PORT: 9394 - SELF_HOSTED: "true" - STORE_GEODATA: "true" + RAILS_ENV: ${RAILS_ENV:-development} + REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379} + 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} + MIN_MINUTES_SPENT_IN_CITY: ${MIN_MINUTES_SPENT_IN_CITY:-60} + APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1} + TIME_ZONE: ${TIME_ZONE:-Europe/London} + APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http} + PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-false} + PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST:-0.0.0.0} + PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394} + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-"CHANGE_ME"} + RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-true} + SELF_HOSTED: ${SELF_HOSTED:-true} + STORE_GEODATA: ${STORE_GEODATA:-true} logging: driver: "json-file" options: - max-size: "100m" - max-file: "5" + max-size: ${LOG_MAX_SIZE:-100m} + max-file: ${LOG_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 @@ -93,8 +99,9 @@ services: deploy: resources: limits: - cpus: '0.50' # Limit CPU usage to 50% of one core - memory: '4G' # Limit memory usage to 4GB + cpus: ${APP_CPU_LIMIT:-0.50} + memory: ${APP_MEMORY_LIMIT:-4G} + dawarich_sidekiq: image: freikin/dawarich:latest container_name: dawarich_sidekiq @@ -110,25 +117,28 @@ services: command: ['sidekiq'] restart: on-failure environment: - RAILS_ENV: development - REDIS_URL: redis://dawarich_redis:6379 - DATABASE_HOST: dawarich_db - DATABASE_USERNAME: postgres - DATABASE_PASSWORD: password - DATABASE_NAME: dawarich_development - APPLICATION_HOSTS: localhost - BACKGROUND_PROCESSING_CONCURRENCY: 10 - APPLICATION_PROTOCOL: http - PROMETHEUS_EXPORTER_ENABLED: "false" - PROMETHEUS_EXPORTER_HOST: dawarich_app - PROMETHEUS_EXPORTER_PORT: 9394 - SELF_HOSTED: "true" - STORE_GEODATA: "true" + RAILS_ENV: ${RAILS_ENV:-development} + REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379} + 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} + APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1} + BACKGROUND_PROCESSING_CONCURRENCY: ${BACKGROUND_PROCESSING_CONCURRENCY:-10} + APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http} + PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-false} + PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST_SIDEKIQ:-dawarich_app} + PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394} + SECRET_KEY_BASE: ${SECRET_KEY_BASE:-"CHANGE_ME"} + RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-true} + SELF_HOSTED: ${SELF_HOSTED:-true} + STORE_GEODATA: ${STORE_GEODATA:-true} logging: driver: "json-file" options: - max-size: "100m" - max-file: "5" + max-size: ${LOG_MAX_SIZE:-100m} + max-file: ${LOG_MAX_FILE:-5} healthcheck: test: [ "CMD-SHELL", "pgrep -f sidekiq" ] interval: 10s diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..1906d091 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,115 @@ +# E2E Tests + +End-to-end tests for Dawarich using Playwright. + +## Running Tests + +```bash +# Run all tests +npx playwright test + +# Run specific test file +npx playwright test e2e/map/map-controls.spec.js + +# Run tests in headed mode (watch browser) +npx playwright test --headed + +# Run tests in debug mode +npx playwright test --debug + +# Run tests sequentially (avoid parallel issues) +npx playwright test --workers=1 +``` + +## Structure + +``` +e2e/ +├── setup/ # Test setup and authentication +├── helpers/ # Shared helper functions +├── map/ # Map-related tests (40 tests total) +└── temp/ # Playwright artifacts (screenshots, videos) +``` + +### Test Files + +**Map Tests (62 tests)** +- `map-controls.spec.js` - Basic map controls, zoom, tile layers (5 tests) +- `map-layers.spec.js` - Layer toggles: Routes, Heatmap, Fog, etc. (8 tests) +- `map-points.spec.js` - Point interactions and deletion (4 tests) +- `map-visits.spec.js` - Confirmed visit interactions and management (5 tests) +- `map-suggested-visits.spec.js` - Suggested visit interactions (confirm/decline) (6 tests) +- `map-add-visit.spec.js` - Add visit control and form (8 tests) +- `map-selection-tool.spec.js` - Selection tool functionality (4 tests) +- `map-calendar-panel.spec.js` - Calendar panel navigation (9 tests) +- `map-side-panel.spec.js` - Side panel (visits drawer) functionality (13 tests)* +- `map-bulk-delete.spec.js` - Bulk point deletion (12 tests) + +\* Some side panel tests may be skipped if demo data doesn't contain visits + +## Helper Functions + +### Map Helpers (`helpers/map.js`) +- `waitForMap(page)` - Wait for Leaflet map initialization +- `enableLayer(page, layerName)` - Enable a map layer by name +- `clickConfirmedVisit(page)` - Click first confirmed visit circle +- `clickSuggestedVisit(page)` - Click first suggested visit circle +- `getMapZoom(page)` - Get current map zoom level + +### Navigation Helpers (`helpers/navigation.js`) +- `closeOnboardingModal(page)` - Close getting started modal +- `navigateToDate(page, startDate, endDate)` - Navigate to specific date range +- `navigateToMap(page)` - Navigate to map page with setup + +### Selection Helpers (`helpers/selection.js`) +- `drawSelectionRectangle(page, options)` - Draw selection on map +- `enableSelectionMode(page)` - Enable area selection tool + +## Common Patterns + +### Basic Test Template +```javascript +import { test, expect } from '@playwright/test'; +import { navigateToMap } from '../helpers/navigation.js'; +import { waitForMap } from '../helpers/map.js'; + +test('my test', async ({ page }) => { + await navigateToMap(page); + await waitForMap(page); + // Your test logic +}); +``` + +### Testing Map Layers +```javascript +import { enableLayer } from '../helpers/map.js'; + +await enableLayer(page, 'Routes'); +await enableLayer(page, 'Heatmap'); +``` + +## Debugging + +### View Test Artifacts +```bash +# Open HTML report +npx playwright show-report + +# Screenshots and videos are in: +test-results/ +``` + +### Common Issues +- **Flaky tests**: Run with `--workers=1` to avoid parallel interference +- **Timeout errors**: Increase timeout in test or use `page.waitForTimeout()` +- **Map not loading**: Ensure `waitForMap()` is called after navigation + +## CI/CD + +Tests run with: +- 1 worker (sequential) +- 2 retries on failure +- Screenshots/videos on failure +- JUnit XML reports + +See `playwright.config.js` for full configuration. diff --git a/e2e/helpers/map.js b/e2e/helpers/map.js new file mode 100644 index 00000000..551bf8c8 --- /dev/null +++ b/e2e/helpers/map.js @@ -0,0 +1,84 @@ +/** + * Map helper functions for Playwright tests + */ + +/** + * Wait for Leaflet map to be fully initialized + * @param {Page} page - Playwright page object + */ +export async function waitForMap(page) { + await page.waitForFunction(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container && container._leaflet_id !== undefined; + }, { timeout: 10000 }); +} + +/** + * Enable a map layer by name + * @param {Page} page - Playwright page object + * @param {string} layerName - Name of the layer to enable (e.g., "Routes", "Heatmap") + */ +export async function enableLayer(page, layerName) { + await page.locator('.leaflet-control-layers').hover(); + await page.waitForTimeout(300); + + const checkbox = page.locator(`.leaflet-control-layers-overlays label:has-text("${layerName}") input[type="checkbox"]`); + const isChecked = await checkbox.isChecked(); + + if (!isChecked) { + await checkbox.check(); + await page.waitForTimeout(1000); + } +} + +/** + * Click on the first confirmed visit circle on the map + * @param {Page} page - Playwright page object + * @returns {Promise} - True if a visit was clicked, false otherwise + */ +export async function clickConfirmedVisit(page) { + return await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.confirmedVisitCircles?._layers) { + const layers = controller.visitsManager.confirmedVisitCircles._layers; + const firstVisit = Object.values(layers)[0]; + if (firstVisit) { + firstVisit.fire('click'); + return true; + } + } + return false; + }); +} + +/** + * Click on the first suggested visit circle on the map + * @param {Page} page - Playwright page object + * @returns {Promise} - True if a visit was clicked, false otherwise + */ +export async function clickSuggestedVisit(page) { + return await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.suggestedVisitCircles?._layers) { + const layers = controller.visitsManager.suggestedVisitCircles._layers; + const firstVisit = Object.values(layers)[0]; + if (firstVisit) { + firstVisit.fire('click'); + return true; + } + } + return false; + }); +} + +/** + * Get current map zoom level + * @param {Page} page - Playwright page object + * @returns {Promise} - Current zoom level or null + */ +export async function getMapZoom(page) { + return await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.map?.getZoom() || null; + }); +} diff --git a/e2e/helpers/navigation.js b/e2e/helpers/navigation.js new file mode 100644 index 00000000..dde3c411 --- /dev/null +++ b/e2e/helpers/navigation.js @@ -0,0 +1,45 @@ +/** + * Navigation and UI helper functions for Playwright tests + */ + +/** + * Close the onboarding modal if it's open + * @param {Page} page - Playwright page object + */ +export async function closeOnboardingModal(page) { + const onboardingModal = page.locator('#getting_started'); + const isModalOpen = await onboardingModal.evaluate((dialog) => dialog.open).catch(() => false); + if (isModalOpen) { + await page.locator('#getting_started button.btn-primary').click(); + await page.waitForTimeout(500); + } +} + +/** + * Navigate to the map page and close onboarding modal + * @param {Page} page - Playwright page object + */ +export async function navigateToMap(page) { + await page.goto('/map'); + await closeOnboardingModal(page); +} + +/** + * Navigate to a specific date range on the map + * @param {Page} page - Playwright page object + * @param {string} startDate - Start date in format 'YYYY-MM-DDTHH:mm' + * @param {string} endDate - End date in format 'YYYY-MM-DDTHH:mm' + */ +export async function navigateToDate(page, startDate, endDate) { + const startInput = page.locator('input[type="datetime-local"][name="start_at"]'); + await startInput.clear(); + await startInput.fill(startDate); + + const endInput = page.locator('input[type="datetime-local"][name="end_at"]'); + await endInput.clear(); + await endInput.fill(endDate); + + await page.click('input[type="submit"][value="Search"]'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); +} diff --git a/e2e/helpers/selection.js b/e2e/helpers/selection.js new file mode 100644 index 00000000..1415c296 --- /dev/null +++ b/e2e/helpers/selection.js @@ -0,0 +1,64 @@ +/** + * Selection and drawing helper functions for Playwright tests + */ + +/** + * Enable selection mode by clicking the selection tool button + * @param {Page} page - Playwright page object + */ +export async function enableSelectionMode(page) { + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); +} + +/** + * Draw a selection rectangle on the map + * @param {Page} page - Playwright page object + * @param {Object} options - Drawing options + * @param {number} options.startX - Start X position (0-1 as fraction of width, default: 0.2) + * @param {number} options.startY - Start Y position (0-1 as fraction of height, default: 0.2) + * @param {number} options.endX - End X position (0-1 as fraction of width, default: 0.8) + * @param {number} options.endY - End Y position (0-1 as fraction of height, default: 0.8) + * @param {number} options.steps - Number of steps for smooth drag (default: 10) + */ +export async function drawSelectionRectangle(page, options = {}) { + const { + startX = 0.2, + startY = 0.2, + endX = 0.8, + endY = 0.8, + steps = 10 + } = options; + + // Click area selection tool + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + // Get map container bounding box + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + + // Calculate absolute positions + const absStartX = bbox.x + bbox.width * startX; + const absStartY = bbox.y + bbox.height * startY; + const absEndX = bbox.x + bbox.width * endX; + const absEndY = bbox.y + bbox.height * endY; + + // Draw rectangle + await page.mouse.move(absStartX, absStartY); + await page.mouse.down(); + await page.mouse.move(absEndX, absEndY, { steps }); + await page.mouse.up(); + + // Wait for API calls and drawer animations + await page.waitForTimeout(2000); + + // Wait for drawer to open (it should open automatically after selection) + await page.waitForSelector('#visits-drawer.open', { timeout: 15000 }); + + // Wait for delete button to appear in the drawer (indicates selection is complete) + await page.waitForSelector('#delete-selection-button', { timeout: 15000 }); + await page.waitForTimeout(500); // Brief wait for UI to stabilize +} diff --git a/e2e/live-map-handler.spec.js b/e2e/live-map-handler.spec.js deleted file mode 100644 index a79fddcf..00000000 --- a/e2e/live-map-handler.spec.js +++ /dev/null @@ -1,134 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Test to verify the refactored LiveMapHandler class works correctly - */ - -test.describe('LiveMapHandler Refactoring', () => { - let page; - let context; - - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - page = await context.newPage(); - - // Sign in - await page.goto('/users/sign_in'); - await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 }); - await page.fill('input[name="user[email]"]', 'demo@dawarich.app'); - await page.fill('input[name="user[password]"]', 'password'); - await page.click('input[type="submit"][value="Log in"]'); - await page.waitForURL('/map', { timeout: 10000 }); - }); - - test.afterAll(async () => { - await page.close(); - await context.close(); - }); - - test('should have LiveMapHandler class imported and available', async () => { - // Navigate to map - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Check if LiveMapHandler is available in the code - const hasLiveMapHandler = await page.evaluate(() => { - // Check if the LiveMapHandler class exists in the bundled JavaScript - const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML); - const allJavaScript = scripts.join(' '); - - const hasLiveMapHandlerClass = allJavaScript.includes('LiveMapHandler') || - allJavaScript.includes('live_map_handler'); - const hasAppendPointDelegation = allJavaScript.includes('liveMapHandler.appendPoint') || - allJavaScript.includes('this.liveMapHandler'); - - return { - hasLiveMapHandlerClass, - hasAppendPointDelegation, - totalJSSize: allJavaScript.length, - scriptCount: scripts.length - }; - }); - - console.log('LiveMapHandler availability:', hasLiveMapHandler); - - // The test is informational - we verify the refactoring is present in source - expect(hasLiveMapHandler.scriptCount).toBeGreaterThan(0); - }); - - test('should have proper delegation in maps controller', async () => { - // Navigate to map - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Verify the controller structure - const controllerAnalysis = await page.evaluate(() => { - const mapElement = document.querySelector('#map'); - const controllers = mapElement?._stimulus_controllers; - const mapController = controllers?.find(c => c.identifier === 'maps'); - - if (mapController) { - const hasAppendPoint = typeof mapController.appendPoint === 'function'; - const methodSource = hasAppendPoint ? mapController.appendPoint.toString() : ''; - - return { - hasController: true, - hasAppendPoint, - // Check if appendPoint delegates to LiveMapHandler - usesDelegation: methodSource.includes('liveMapHandler') || methodSource.includes('LiveMapHandler'), - methodLength: methodSource.length, - isSimpleMethod: methodSource.length < 500 // Should be much smaller now - }; - } - - return { - hasController: false, - message: 'Controller not found in test environment' - }; - }); - - console.log('Controller delegation analysis:', controllerAnalysis); - - // Test passes either way since we've implemented the refactoring - if (controllerAnalysis.hasController) { - // If controller exists, verify it's using delegation - expect(controllerAnalysis.hasAppendPoint).toBe(true); - // The new appendPoint method should be much smaller (delegation only) - expect(controllerAnalysis.isSimpleMethod).toBe(true); - } else { - // Controller not found - this is the current test environment limitation - console.log('Controller not accessible in test, but refactoring implemented in source'); - } - - expect(true).toBe(true); // Test always passes as verification - }); - - test('should maintain backward compatibility', async () => { - // Navigate to map - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Verify basic map functionality still works - const mapFunctionality = await page.evaluate(() => { - return { - hasLeafletContainer: !!document.querySelector('.leaflet-container'), - hasMapElement: !!document.querySelector('#map'), - hasApiKey: !!document.querySelector('#map')?.dataset?.api_key, - leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length, - hasDataController: document.querySelector('#map')?.hasAttribute('data-controller') - }; - }); - - console.log('Map functionality check:', mapFunctionality); - - // Verify all core functionality remains intact - expect(mapFunctionality.hasLeafletContainer).toBe(true); - expect(mapFunctionality.hasMapElement).toBe(true); - expect(mapFunctionality.hasApiKey).toBe(true); - expect(mapFunctionality.hasDataController).toBe(true); - expect(mapFunctionality.leafletElementCount).toBeGreaterThan(10); - }); -}); \ No newline at end of file diff --git a/e2e/live-mode.spec.js b/e2e/live-mode.spec.js deleted file mode 100644 index 22845f76..00000000 --- a/e2e/live-mode.spec.js +++ /dev/null @@ -1,1216 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * These tests cover the Live Mode functionality of the /map page - * Live Mode allows real-time streaming of GPS points via WebSocket - */ - -test.describe('Live Mode Functionality', () => { - let page; - let context; - - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - page = await context.newPage(); - - // Sign in once for all tests - await page.goto('/users/sign_in'); - await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 }); - - await page.fill('input[name="user[email]"]', 'demo@dawarich.app'); - await page.fill('input[name="user[password]"]', 'password'); - await page.click('input[type="submit"][value="Log in"]'); - - // Wait for redirect to map page - await page.waitForURL('/map', { timeout: 10000 }); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - }); - - test.afterAll(async () => { - await page.close(); - await context.close(); - }); - - test.beforeEach(async () => { - // Navigate to June 4, 2025 where we have test data - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Wait for map controller to be initialized - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Give controllers time to connect (best effort) - await page.waitForTimeout(3000); - }); - - test.describe('Live Mode Debug', () => { - test('should debug current map state and point processing', async () => { - // Don't enable live mode initially - check base state - console.log('=== DEBUGGING MAP STATE ==='); - - // Check initial state - const initialState = await page.evaluate(() => { - const mapElement = document.querySelector('#map'); - - // Check various ways to find the controller - const stimulusControllers = mapElement?._stimulus_controllers; - const mapController = stimulusControllers?.find(c => c.identifier === 'maps'); - - // Check if Stimulus is loaded at all - const hasStimulus = !!(window.Stimulus || window.Application); - - // Check data attributes - const hasDataController = mapElement?.hasAttribute('data-controller'); - const dataControllerValue = mapElement?.getAttribute('data-controller'); - - return { - // Map element data - hasMapElement: !!mapElement, - hasApiKey: !!mapElement?.dataset.api_key, - hasCoordinates: !!mapElement?.dataset.coordinates, - hasUserSettings: !!mapElement?.dataset.user_settings, - - // Stimulus debugging - hasStimulus: hasStimulus, - hasDataController: hasDataController, - dataControllerValue: dataControllerValue, - hasStimulusControllers: !!stimulusControllers, - stimulusControllersCount: stimulusControllers?.length || 0, - controllerIdentifiers: stimulusControllers?.map(c => c.identifier) || [], - - // Map controller - hasMapController: !!mapController, - controllerProps: mapController ? Object.keys(mapController) : [], - - // Live mode specific - liveMapEnabled: mapController?.liveMapEnabled, - - // Markers and data - markersLength: mapController?.markers?.length || 0, - markersArrayLength: mapController?.markersArray?.length || 0, - - // WebSocket - hasConsumer: !!(window.App?.cable || window.consumer), - - // Date range from URL - currentUrl: window.location.href - }; - }); - - console.log('Initial state:', JSON.stringify(initialState, null, 2)); - - // Check DOM elements - const domCounts = await page.evaluate(() => ({ - markerElements: document.querySelectorAll('.leaflet-marker-pane .leaflet-marker-icon').length, - polylineElements: document.querySelectorAll('.leaflet-overlay-pane path').length, - totalLeafletElements: document.querySelectorAll('[class*="leaflet"]').length - })); - - console.log('DOM counts:', domCounts); - - // Now enable live mode and check again - await enableLiveMode(page); - - const afterLiveModeState = await page.evaluate(() => { - const mapController = document.querySelector('#map')?._stimulus_controllers?.find(c => c.identifier === 'maps'); - return { - liveMapEnabled: mapController?.liveMapEnabled, - markersLength: mapController?.markers?.length || 0, - hasAppendPointMethod: typeof mapController?.appendPoint === 'function' - }; - }); - - console.log('After enabling live mode:', afterLiveModeState); - - // Try direct Leaflet map manipulation to trigger memory leak - console.log('Testing direct Leaflet map manipulation...'); - const directResult = await page.evaluate(() => { - // Try multiple ways to find the Leaflet map instance - const mapContainer = document.querySelector('#map [data-maps-target="container"]'); - - // Debug info - const debugInfo = { - hasMapContainer: !!mapContainer, - hasLeafletId: mapContainer?._leaflet_id, - leafletId: mapContainer?._leaflet_id, - hasL: typeof L !== 'undefined', - windowKeys: Object.keys(window).filter(k => k.includes('L_')).slice(0, 5) - }; - - if (!mapContainer) { - return { success: false, error: 'No map container found', debug: debugInfo }; - } - - // Try different ways to get the map - let map = null; - - // Method 1: Direct reference - if (mapContainer._leaflet_id) { - map = window[`L_${mapContainer._leaflet_id}`] || mapContainer._leaflet_map; - } - - // Method 2: Check if container has map directly - if (!map && mapContainer._leaflet_map) { - map = mapContainer._leaflet_map; - } - - // Method 3: Check Leaflet's internal registry - if (!map && typeof L !== 'undefined' && L.Util && L.Util.stamp && mapContainer._leaflet_id) { - // Try to find in Leaflet's internal map registry - if (window.L && window.L._map) { - map = window.L._map; - } - } - - // Method 4: Try to find any existing map instance in the DOM - if (!map) { - const leafletContainers = document.querySelectorAll('.leaflet-container'); - for (let container of leafletContainers) { - if (container._leaflet_map) { - map = container._leaflet_map; - break; - } - } - } - - if (map && typeof L !== 'undefined') { - try { - // Create a simple marker to test if the map works - const testMarker = L.marker([52.52, 13.40], { - icon: L.divIcon({ - className: 'test-marker', - html: '
    ', - iconSize: [10, 10] - }) - }); - - // Add directly to map - testMarker.addTo(map); - - return { - success: true, - error: null, - markersAdded: 1, - debug: debugInfo - }; - } catch (error) { - return { success: false, error: error.message, debug: debugInfo }; - } - } - - return { success: false, error: 'No usable Leaflet map found', debug: debugInfo }; - }); - - // Check after direct manipulation - const afterDirectCall = await page.evaluate(() => { - return { - domMarkers: document.querySelectorAll('.leaflet-marker-pane .leaflet-marker-icon').length, - domLayerGroups: document.querySelectorAll('.leaflet-layer').length, - totalLeafletElements: document.querySelectorAll('[class*="leaflet"]').length - }; - }); - - console.log('Direct manipulation result:', directResult); - console.log('After direct manipulation:', afterDirectCall); - - // Try WebSocket simulation - console.log('Testing WebSocket simulation...'); - const wsResult = await simulateWebSocketMessage(page, { - lat: 52.521008, - lng: 13.405954, - timestamp: new Date('2025-06-04T12:01:00').getTime(), - id: Date.now() + 1 - }); - - console.log('WebSocket result:', wsResult); - - // Final check - const finalState = await page.evaluate(() => { - const mapController = document.querySelector('#map')?._stimulus_controllers?.find(c => c.identifier === 'maps'); - return { - markersLength: mapController?.markers?.length || 0, - markersArrayLength: mapController?.markersArray?.length || 0, - domMarkers: document.querySelectorAll('.leaflet-marker-pane .leaflet-marker-icon').length, - domPolylines: document.querySelectorAll('.leaflet-overlay-pane path').length - }; - }); - - console.log('Final state:', finalState); - console.log('=== END DEBUGGING ==='); - - // This test is just for debugging, so always pass - expect(true).toBe(true); - }); - }); - - test.describe('Live Mode Settings', () => { - test('should have live mode checkbox in settings panel', async () => { - // Open settings panel - await page.waitForSelector('.map-settings-button', { timeout: 10000 }); - const settingsButton = page.locator('.map-settings-button'); - await settingsButton.click(); - await page.waitForTimeout(500); - - // Verify live mode checkbox exists - const liveMapCheckbox = page.locator('#live_map_enabled'); - await expect(liveMapCheckbox).toBeVisible(); - - // Verify checkbox has proper attributes - await expect(liveMapCheckbox).toHaveAttribute('type', 'checkbox'); - await expect(liveMapCheckbox).toHaveAttribute('name', 'live_map_enabled'); - - // Verify checkbox label exists - const liveMapLabel = page.locator('label[for="live_map_enabled"]'); - await expect(liveMapLabel).toBeVisible(); - - // Close settings panel - await settingsButton.click(); - await page.waitForTimeout(500); - }); - - test('should enable and disable live mode via settings', async () => { - // Open settings panel - const settingsButton = page.locator('.map-settings-button'); - await settingsButton.click(); - await page.waitForTimeout(500); - - const liveMapCheckbox = page.locator('#live_map_enabled'); - const submitButton = page.locator('#settings-form button[type="submit"]'); - - // Ensure elements are visible - await expect(liveMapCheckbox).toBeVisible(); - await expect(submitButton).toBeVisible(); - - // Get initial state - const initiallyChecked = await liveMapCheckbox.isChecked(); - - // Toggle live mode - if (initiallyChecked) { - await liveMapCheckbox.uncheck(); - } else { - await liveMapCheckbox.check(); - } - - // Verify checkbox state changed - const newState = await liveMapCheckbox.isChecked(); - expect(newState).toBe(!initiallyChecked); - - // Submit the form - await submitButton.click(); - await page.waitForTimeout(3000); // Longer wait for form submission - - // Check if panel closed after submission or stayed open - const panelStillVisible = await page.locator('.leaflet-settings-panel').isVisible().catch(() => false); - - if (panelStillVisible) { - // Panel stayed open - verify the checkbox state directly - const persistedCheckbox = page.locator('#live_map_enabled'); - await expect(persistedCheckbox).toBeVisible(); - const persistedState = await persistedCheckbox.isChecked(); - expect(persistedState).toBe(newState); - - // Reset to original state for cleanup - if (persistedState !== initiallyChecked) { - await persistedCheckbox.click(); - await submitButton.click(); - await page.waitForTimeout(2000); - } - - // Close settings panel - await settingsButton.click(); - await page.waitForTimeout(500); - } else { - // Panel closed - reopen to verify persistence - await settingsButton.click(); - await page.waitForTimeout(1000); - - const persistedCheckbox = page.locator('#live_map_enabled'); - await expect(persistedCheckbox).toBeVisible(); - - // Verify the setting was persisted - const persistedState = await persistedCheckbox.isChecked(); - expect(persistedState).toBe(newState); - - // Reset to original state for cleanup - if (persistedState !== initiallyChecked) { - await persistedCheckbox.click(); - const resetSubmitButton = page.locator('#settings-form button[type="submit"]'); - await resetSubmitButton.click(); - await page.waitForTimeout(2000); - } - - // Close settings panel - await settingsButton.click(); - await page.waitForTimeout(500); - } - }); - }); - - test.describe('WebSocket Connection Management', () => { - test('should establish WebSocket connection when live mode is enabled', async () => { - // Enable live mode first - await enableLiveMode(page); - - // Monitor WebSocket connections - const wsConnections = []; - page.on('websocket', ws => { - console.log(`WebSocket connection: ${ws.url()}`); - wsConnections.push(ws); - }); - - // Reload page to trigger WebSocket connection with live mode enabled - await page.reload(); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - await page.waitForTimeout(3000); // Wait for WebSocket connection - - // Verify WebSocket connection was established - // Note: This might not work in all test environments, so we'll also check for JavaScript evidence - const hasWebSocketConnection = await page.evaluate(() => { - // Check if ActionCable consumer exists and has subscriptions - return window.App && window.App.cable && window.App.cable.subscriptions; - }); - - if (hasWebSocketConnection) { - console.log('WebSocket connection established via ActionCable'); - } else { - // Alternative check: look for PointsChannel subscription in the DOM/JavaScript - const hasPointsChannelSubscription = await page.evaluate(() => { - // Check for evidence of PointsChannel subscription - return document.querySelector('[data-controller*="maps"]') !== null; - }); - expect(hasPointsChannelSubscription).toBe(true); - } - }); - - test('should handle WebSocket connection errors gracefully', async () => { - // Enable live mode - await enableLiveMode(page); - - // Monitor console errors - const consoleErrors = []; - page.on('console', message => { - if (message.type() === 'error') { - consoleErrors.push(message.text()); - } - }); - - // Verify initial state - map should be working - await expect(page.locator('.leaflet-container')).toBeVisible(); - await expect(page.locator('.leaflet-control-layers')).toBeVisible(); - - // Test connection resilience by simulating various network conditions - try { - // Simulate brief network interruption - await page.context().setOffline(true); - await page.waitForTimeout(1000); // Brief disconnection - - // Restore network - await page.context().setOffline(false); - await page.waitForTimeout(2000); // Wait for reconnection - - // Verify map still functions after network interruption - await expect(page.locator('.leaflet-container')).toBeVisible(); - await expect(page.locator('.leaflet-control-layers')).toBeVisible(); - - // Test basic map interactions still work - const layerControl = page.locator('.leaflet-control-layers'); - await layerControl.click(); - - // Wait for layer control to open, with fallback - try { - await expect(page.locator('.leaflet-control-layers-list')).toBeVisible({ timeout: 3000 }); - } catch (e) { - // Layer control might not expand in test environment, just check it's clickable - console.log('Layer control may not expand in test environment'); - } - - // Verify settings panel still works - const settingsButton = page.locator('.map-settings-button'); - await settingsButton.click(); - await page.waitForTimeout(500); - - await expect(page.locator('.leaflet-settings-panel')).toBeVisible(); - - // Close settings panel - await settingsButton.click(); - await page.waitForTimeout(500); - - } catch (error) { - console.log('Network simulation error (expected in some test environments):', error.message); - - // Even if network simulation fails, verify basic functionality - await expect(page.locator('.leaflet-container')).toBeVisible(); - await expect(page.locator('.leaflet-control-layers')).toBeVisible(); - } - - // WebSocket errors might occur but shouldn't break the application - const applicationRemainsStable = await page.locator('.leaflet-container').isVisible(); - expect(applicationRemainsStable).toBe(true); - - console.log(`Console errors detected during connection test: ${consoleErrors.length}`); - }); - }); - - test.describe('Point Streaming and Memory Management', () => { - test('should handle single point addition without memory leaks', async () => { - // Enable live mode - await enableLiveMode(page); - - // Get initial memory baseline - const initialMemory = await getMemoryUsage(page); - - // Get initial marker count - const initialMarkerCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); - - // Simulate a single point being received via WebSocket - // Using coordinates from June 4, 2025 test data range - await simulatePointReceived(page, { - lat: 52.520008, // Berlin coordinates (matching existing test data) - lng: 13.404954, - timestamp: new Date('2025-06-04T12:00:00').getTime(), - id: Date.now() - }); - - await page.waitForTimeout(1000); // Wait for point processing - - // Verify point was added to map - const newMarkerCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); - expect(newMarkerCount).toBeGreaterThanOrEqual(initialMarkerCount); - - // Check memory usage hasn't increased dramatically - const finalMemory = await getMemoryUsage(page); - const memoryIncrease = finalMemory.usedJSHeapSize - initialMemory.usedJSHeapSize; - - // Allow for reasonable memory increase (less than 50MB for a single point) - expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024); - - console.log(`Memory increase for single point: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`); - }); - - test('should handle multiple point additions without exponential memory growth', async () => { - // Enable live mode - await enableLiveMode(page); - - // Get initial memory baseline - const initialMemory = await getMemoryUsage(page); - const memoryMeasurements = [initialMemory.usedJSHeapSize]; - - // Simulate multiple points being received - const pointCount = 10; - const baseTimestamp = new Date('2025-06-04T12:00:00').getTime(); - for (let i = 0; i < pointCount; i++) { - await simulatePointReceived(page, { - lat: 52.520008 + (i * 0.001), // Slightly different positions around Berlin - lng: 13.404954 + (i * 0.001), - timestamp: baseTimestamp + (i * 60000), // 1 minute intervals - id: baseTimestamp + i - }); - - await page.waitForTimeout(200); // Small delay between points - - // Measure memory every few points - if ((i + 1) % 3 === 0) { - const currentMemory = await getMemoryUsage(page); - memoryMeasurements.push(currentMemory.usedJSHeapSize); - } - } - - // Final memory measurement - const finalMemory = await getMemoryUsage(page); - memoryMeasurements.push(finalMemory.usedJSHeapSize); - - // Analyze memory growth pattern - const totalMemoryIncrease = finalMemory.usedJSHeapSize - initialMemory.usedJSHeapSize; - const averageIncreasePerPoint = totalMemoryIncrease / pointCount; - - console.log(`Total memory increase for ${pointCount} points: ${(totalMemoryIncrease / 1024 / 1024).toFixed(2)}MB`); - console.log(`Average memory per point: ${(averageIncreasePerPoint / 1024 / 1024).toFixed(2)}MB`); - - // Memory increase should be reasonable (less than 10MB per point) - expect(averageIncreasePerPoint).toBeLessThan(10 * 1024 * 1024); - - // Check for exponential growth by comparing early vs late increases - if (memoryMeasurements.length >= 3) { - const earlyIncrease = memoryMeasurements[1] - memoryMeasurements[0]; - const lateIncrease = memoryMeasurements[memoryMeasurements.length - 1] - memoryMeasurements[memoryMeasurements.length - 2]; - const growthRatio = lateIncrease / Math.max(earlyIncrease, 1024 * 1024); // Avoid division by zero - - // Growth ratio should not be exponential (less than 10x increase) - expect(growthRatio).toBeLessThan(10); - console.log(`Memory growth ratio (late/early): ${growthRatio.toFixed(2)}`); - } - }); - - test('should properly cleanup layers during continuous point streaming', async () => { - // Enable live mode - await enableLiveMode(page); - - // Count initial DOM nodes - const initialNodeCount = await page.evaluate(() => { - return document.querySelectorAll('.leaflet-marker-pane *, .leaflet-overlay-pane *').length; - }); - - // Simulate rapid point streaming - const streamPoints = async (count) => { - const baseTimestamp = new Date('2025-06-04T12:00:00').getTime(); - for (let i = 0; i < count; i++) { - await simulatePointReceived(page, { - lat: 52.520008 + (Math.random() * 0.01), // Random positions around Berlin - lng: 13.404954 + (Math.random() * 0.01), - timestamp: baseTimestamp + (i * 10000), // 10 second intervals for rapid streaming - id: baseTimestamp + i - }); - - // Very small delay to simulate rapid streaming - await page.waitForTimeout(50); - } - }; - - // Stream first batch - await streamPoints(5); - await page.waitForTimeout(1000); - - const midNodeCount = await page.evaluate(() => { - return document.querySelectorAll('.leaflet-marker-pane *, .leaflet-overlay-pane *').length; - }); - - // Stream second batch - await streamPoints(5); - await page.waitForTimeout(1000); - - const finalNodeCount = await page.evaluate(() => { - return document.querySelectorAll('.leaflet-marker-pane *, .leaflet-overlay-pane *').length; - }); - - console.log(`DOM nodes - Initial: ${initialNodeCount}, Mid: ${midNodeCount}, Final: ${finalNodeCount}`); - - // DOM nodes should not grow unbounded - // Allow for some growth but not exponential - const nodeGrowthRatio = finalNodeCount / Math.max(initialNodeCount, 1); - expect(nodeGrowthRatio).toBeLessThan(50); // Should not be more than 50x initial nodes - - // Verify layers are being managed properly - const layerElements = await page.evaluate(() => { - const markers = document.querySelectorAll('.leaflet-marker-pane .leaflet-marker-icon'); - const polylines = document.querySelectorAll('.leaflet-overlay-pane path'); - return { - markerCount: markers.length, - polylineCount: polylines.length - }; - }); - - console.log(`Final counts - Markers: ${layerElements.markerCount}, Polylines: ${layerElements.polylineCount}`); - - // Verify we have reasonable number of elements (not accumulating infinitely) - expect(layerElements.markerCount).toBeLessThan(1000); - expect(layerElements.polylineCount).toBeLessThan(1000); - }); - - test('should handle map view updates during point streaming', async () => { - // Enable live mode - await enableLiveMode(page); - - // Get initial map center - const initialCenter = await page.evaluate(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - if (container && container._leaflet_id) { - const map = window[`L_${container._leaflet_id}`]; - if (map) { - const center = map.getCenter(); - return { lat: center.lat, lng: center.lng }; - } - } - return null; - }); - - // Simulate point at different location (but within reasonable test data range) - const newPointLocation = { - lat: 52.5200, // Slightly different Berlin location - lng: 13.4050, - timestamp: new Date('2025-06-04T14:00:00').getTime(), - id: Date.now() - }; - - await simulatePointReceived(page, newPointLocation); - await page.waitForTimeout(2000); // Wait for map to potentially update - - // Verify map view was updated to new location - const newCenter = await page.evaluate(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - if (container && container._leaflet_id) { - const map = window[`L_${container._leaflet_id}`]; - if (map) { - const center = map.getCenter(); - return { lat: center.lat, lng: center.lng }; - } - } - return null; - }); - - if (initialCenter && newCenter) { - // Map should have moved to the new point location - const latDifference = Math.abs(newCenter.lat - newPointLocation.lat); - const lngDifference = Math.abs(newCenter.lng - newPointLocation.lng); - - // Should be close to the new point (within reasonable tolerance) - expect(latDifference).toBeLessThan(0.1); - expect(lngDifference).toBeLessThan(0.1); - - console.log(`Map moved from [${initialCenter.lat}, ${initialCenter.lng}] to [${newCenter.lat}, ${newCenter.lng}]`); - } - }); - - test('should handle realistic WebSocket message streaming', async () => { - // Enable live mode - await enableLiveMode(page); - - // Debug: Check if live mode is actually enabled - const liveMode = await page.evaluate(() => { - const mapElement = document.querySelector('#map'); - const userSettings = mapElement?.dataset.user_settings; - if (userSettings) { - try { - const settings = JSON.parse(userSettings); - return settings.live_map_enabled; - } catch (e) { - return 'parse_error'; - } - } - return 'no_settings'; - }); - console.log('Live mode enabled:', liveMode); - - // Debug: Check WebSocket connection - const wsStatus = await page.evaluate(() => { - const consumer = window.App?.cable || window.consumer; - if (consumer && consumer.subscriptions) { - const pointsSubscription = consumer.subscriptions.subscriptions.find(sub => - sub.identifier && JSON.parse(sub.identifier).channel === 'PointsChannel' - ); - return { - hasConsumer: !!consumer, - hasSubscriptions: !!consumer.subscriptions, - subscriptionCount: consumer.subscriptions.subscriptions?.length || 0, - hasPointsChannel: !!pointsSubscription - }; - } - return { hasConsumer: false, error: 'no_consumer' }; - }); - console.log('WebSocket status:', wsStatus); - - // Get initial memory and marker count - const initialMemory = await getMemoryUsage(page); - const initialMarkerCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); - - console.log('Testing realistic WebSocket message simulation...'); - console.log('Initial markers:', initialMarkerCount); - - // Use the more realistic WebSocket simulation - const pointCount = 15; - const baseTimestamp = new Date('2025-06-04T12:00:00').getTime(); - - for (let i = 0; i < pointCount; i++) { - await simulateWebSocketMessage(page, { - lat: 52.520008 + (i * 0.0005), // Gradual movement - lng: 13.404954 + (i * 0.0005), - timestamp: baseTimestamp + (i * 30000), // 30 second intervals - id: baseTimestamp + i - }); - - // Realistic delay between points - await page.waitForTimeout(100); - - // Monitor memory every 5 points - if ((i + 1) % 5 === 0) { - const currentMemory = await getMemoryUsage(page); - const memoryIncrease = currentMemory.usedJSHeapSize - initialMemory.usedJSHeapSize; - console.log(`After ${i + 1} points: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB increase`); - } - } - - // Final measurements - const finalMemory = await getMemoryUsage(page); - const finalMarkerCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); - - const totalMemoryIncrease = finalMemory.usedJSHeapSize - initialMemory.usedJSHeapSize; - const averageMemoryPerPoint = totalMemoryIncrease / pointCount; - - console.log(`WebSocket simulation - Total memory increase: ${(totalMemoryIncrease / 1024 / 1024).toFixed(2)}MB`); - console.log(`Average memory per point: ${(averageMemoryPerPoint / 1024 / 1024).toFixed(2)}MB`); - console.log(`Markers: ${initialMarkerCount} → ${finalMarkerCount}`); - - // Debug: Check what's in the map data - const mapDebugInfo = await page.evaluate(() => { - const mapController = document.querySelector('#map')?._stimulus_controllers?.find(c => c.identifier === 'maps'); - if (mapController) { - return { - hasMarkers: !!mapController.markers, - markersLength: mapController.markers?.length || 0, - hasMarkersArray: !!mapController.markersArray, - markersArrayLength: mapController.markersArray?.length || 0, - liveMapEnabled: mapController.liveMapEnabled - }; - } - return { error: 'No map controller found' }; - }); - console.log('Map controller debug:', mapDebugInfo); - - // Verify reasonable memory usage (allow more for realistic simulation) - expect(averageMemoryPerPoint).toBeLessThan(20 * 1024 * 1024); // 20MB per point max - expect(finalMarkerCount).toBeGreaterThanOrEqual(initialMarkerCount); - }); - - test('should handle continuous realistic streaming with variable timing', async () => { - // Enable live mode - await enableLiveMode(page); - - // Get initial state - const initialMemory = await getMemoryUsage(page); - const initialDOMNodes = await page.evaluate(() => { - return document.querySelectorAll('.leaflet-marker-pane *, .leaflet-overlay-pane *').length; - }); - - console.log('Testing continuous realistic streaming...'); - - // Use the realistic streaming function - await simulateRealtimeStream(page, { - pointCount: 12, - maxInterval: 500, // Faster for testing - minInterval: 50, - driftRange: 0.002 // More realistic GPS drift - }); - - // Let the system settle - await page.waitForTimeout(1000); - - // Final measurements - const finalMemory = await getMemoryUsage(page); - const finalDOMNodes = await page.evaluate(() => { - return document.querySelectorAll('.leaflet-marker-pane *, .leaflet-overlay-pane *').length; - }); - - const memoryIncrease = finalMemory.usedJSHeapSize - initialMemory.usedJSHeapSize; - const domNodeIncrease = finalDOMNodes - initialDOMNodes; - - console.log(`Realistic streaming - Memory increase: ${(memoryIncrease / 1024 / 1024).toFixed(2)}MB`); - console.log(`DOM nodes: ${initialDOMNodes} → ${finalDOMNodes} (${domNodeIncrease} increase)`); - - // Verify system stability - await expect(page.locator('.leaflet-container')).toBeVisible(); - await expect(page.locator('.leaflet-control-layers')).toBeVisible(); - - // Memory should be reasonable for realistic streaming - expect(memoryIncrease).toBeLessThan(100 * 1024 * 1024); // 100MB max for 12 points - - // DOM nodes shouldn't grow unbounded - expect(domNodeIncrease).toBeLessThan(500); - }); - }); - - test.describe('Live Mode Error Handling', () => { - test('should handle malformed point data gracefully', async () => { - // Enable live mode - await enableLiveMode(page); - - // Monitor console errors - const consoleErrors = []; - page.on('console', message => { - if (message.type() === 'error') { - consoleErrors.push(message.text()); - } - }); - - // Get initial marker count - const initialMarkerCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); - - // Simulate malformed point data - await page.evaluate(() => { - const mapController = document.querySelector('#map')?._stimulus_controllers?.find(c => c.identifier === 'maps'); - if (mapController && mapController.appendPoint) { - // Try various malformed data scenarios - try { - mapController.appendPoint(null); - } catch (e) { - console.log('Handled null data'); - } - - try { - mapController.appendPoint({}); - } catch (e) { - console.log('Handled empty object'); - } - - try { - mapController.appendPoint([]); - } catch (e) { - console.log('Handled empty array'); - } - - try { - mapController.appendPoint(['invalid', 'data']); - } catch (e) { - console.log('Handled invalid array data'); - } - } - }); - - await page.waitForTimeout(1000); - - // Verify map is still functional - await expect(page.locator('.leaflet-container')).toBeVisible(); - - // Marker count should not have changed (malformed data should be rejected) - const finalMarkerCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); - expect(finalMarkerCount).toBe(initialMarkerCount); - - // Some errors are expected from malformed data, but application should continue working - const layerControlWorks = await page.locator('.leaflet-control-layers').isVisible(); - expect(layerControlWorks).toBe(true); - }); - - test('should recover from JavaScript errors during point processing', async () => { - // Enable live mode - await enableLiveMode(page); - - // Inject a temporary error into the point processing - await page.evaluate(() => { - // Temporarily break a method to simulate an error - const originalCreateMarkersArray = window.createMarkersArray; - let errorInjected = false; - - // Override function temporarily to cause an error once - if (window.createMarkersArray) { - window.createMarkersArray = function(...args) { - if (!errorInjected) { - errorInjected = true; - throw new Error('Simulated processing error'); - } - return originalCreateMarkersArray.apply(this, args); - }; - - // Restore original function after a delay - setTimeout(() => { - window.createMarkersArray = originalCreateMarkersArray; - }, 2000); - } - }); - - // Try to add a point (should trigger error first time) - await simulatePointReceived(page, { - lat: 52.520008, - lng: 13.404954, - timestamp: new Date('2025-06-04T13:00:00').getTime(), - id: Date.now() - }); - - await page.waitForTimeout(1000); - - // Verify map is still responsive - await expect(page.locator('.leaflet-container')).toBeVisible(); - - // Try adding another point (should work after recovery) - await page.waitForTimeout(2000); // Wait for function restoration - - await simulatePointReceived(page, { - lat: 52.521008, - lng: 13.405954, - timestamp: new Date('2025-06-04T13:30:00').getTime(), - id: Date.now() + 1000 - }); - - await page.waitForTimeout(1000); - - // Verify map functionality has recovered - const layerControl = page.locator('.leaflet-control-layers'); - await expect(layerControl).toBeVisible(); - - await layerControl.click(); - await expect(page.locator('.leaflet-control-layers-list')).toBeVisible(); - }); - }); -}); - -// Helper functions - -/** - * Enable live mode via settings panel - */ -async function enableLiveMode(page) { - const settingsButton = page.locator('.map-settings-button'); - await settingsButton.click(); - await page.waitForTimeout(500); - - // Ensure settings panel is open - await expect(page.locator('.leaflet-settings-panel')).toBeVisible(); - - const liveMapCheckbox = page.locator('#live_map_enabled'); - await expect(liveMapCheckbox).toBeVisible(); - - const isEnabled = await liveMapCheckbox.isChecked(); - - if (!isEnabled) { - await liveMapCheckbox.check(); - - const submitButton = page.locator('#settings-form button[type="submit"]'); - await expect(submitButton).toBeVisible(); - await submitButton.click(); - await page.waitForTimeout(3000); // Longer wait for settings to save - - // Check if panel closed after submission - const panelStillVisible = await page.locator('.leaflet-settings-panel').isVisible().catch(() => false); - if (panelStillVisible) { - // Close panel manually - await settingsButton.click(); - await page.waitForTimeout(500); - } - } else { - // Already enabled, just close the panel - await settingsButton.click(); - await page.waitForTimeout(500); - } -} - -/** - * Get current memory usage from browser - */ -async function getMemoryUsage(page) { - return await page.evaluate(() => { - if (window.performance && window.performance.memory) { - return { - usedJSHeapSize: window.performance.memory.usedJSHeapSize, - totalJSHeapSize: window.performance.memory.totalJSHeapSize, - jsHeapSizeLimit: window.performance.memory.jsHeapSizeLimit - }; - } - // Fallback if performance.memory is not available - return { - usedJSHeapSize: 0, - totalJSHeapSize: 0, - jsHeapSizeLimit: 0 - }; - }); -} - -/** - * Simulate a point being received via WebSocket - */ -async function simulatePointReceived(page, pointData) { - await page.evaluate((point) => { - const mapController = document.querySelector('#map')?._stimulus_controllers?.find(c => c.identifier === 'maps'); - if (mapController && mapController.appendPoint) { - // Convert point data to the format expected by appendPoint - const pointArray = [ - point.lat, // latitude - point.lng, // longitude - 85, // battery - 100, // altitude - point.timestamp,// timestamp - 0, // velocity - point.id, // id - 'DE' // country - ]; - - try { - mapController.appendPoint(pointArray); - } catch (error) { - console.error('Error in appendPoint:', error); - } - } else { - console.warn('Map controller or appendPoint method not found'); - } - }, pointData); -} - -/** - * Simulate real WebSocket message reception (more realistic) - */ -async function simulateWebSocketMessage(page, pointData) { - const result = await page.evaluate((point) => { - // Find the PointsChannel subscription - const consumer = window.App?.cable || window.consumer; - let debugInfo = { - hasConsumer: !!consumer, - method: 'unknown', - success: false, - error: null - }; - - if (consumer && consumer.subscriptions) { - const pointsSubscription = consumer.subscriptions.subscriptions.find(sub => - sub.identifier && JSON.parse(sub.identifier).channel === 'PointsChannel' - ); - - if (pointsSubscription) { - debugInfo.method = 'websocket'; - // Convert point data to the format sent by the server - const serverMessage = [ - point.lat, // latitude - point.lng, // longitude - 85, // battery - 100, // altitude - point.timestamp,// timestamp - 0, // velocity - point.id, // id - 'DE' // country - ]; - - try { - // Trigger the received callback directly - pointsSubscription.received(serverMessage); - debugInfo.success = true; - } catch (error) { - debugInfo.error = error.message; - console.error('Error in WebSocket message simulation:', error); - } - } else { - debugInfo.method = 'fallback_no_subscription'; - // Fallback to direct appendPoint call - const mapController = document.querySelector('#map')?._stimulus_controllers?.find(c => c.identifier === 'maps'); - if (mapController && mapController.appendPoint) { - const pointArray = [point.lat, point.lng, 85, 100, point.timestamp, 0, point.id, 'DE']; - try { - mapController.appendPoint(pointArray); - debugInfo.success = true; - } catch (error) { - debugInfo.error = error.message; - } - } else { - debugInfo.error = 'No map controller found'; - } - } - } else { - debugInfo.method = 'fallback_no_consumer'; - // Fallback to direct appendPoint call - const mapController = document.querySelector('#map')?._stimulus_controllers?.find(c => c.identifier === 'maps'); - if (mapController && mapController.appendPoint) { - const pointArray = [point.lat, point.lng, 85, 100, point.timestamp, 0, point.id, 'DE']; - try { - mapController.appendPoint(pointArray); - debugInfo.success = true; - } catch (error) { - debugInfo.error = error.message; - } - } else { - debugInfo.error = 'No map controller found'; - } - } - - return debugInfo; - }, pointData); - - // Log debug info for first few calls - if (Math.random() < 0.2) { // Log ~20% of calls to avoid spam - console.log('WebSocket simulation result:', result); - } - - return result; -} - -/** - * Simulate continuous real-time streaming with varying intervals - */ -async function simulateRealtimeStream(page, pointsConfig) { - const { - startLat = 52.520008, - startLng = 13.404954, - pointCount = 20, - maxInterval = 5000, // 5 seconds max between points - minInterval = 100, // 100ms min between points - driftRange = 0.001 // How much coordinates can drift - } = pointsConfig; - - let currentLat = startLat; - let currentLng = startLng; - const baseTimestamp = new Date('2025-06-04T12:00:00').getTime(); - - for (let i = 0; i < pointCount; i++) { - // Simulate GPS drift - currentLat += (Math.random() - 0.5) * driftRange; - currentLng += (Math.random() - 0.5) * driftRange; - - // Random interval to simulate real-world timing variations - const interval = Math.random() * (maxInterval - minInterval) + minInterval; - - const pointData = { - lat: currentLat, - lng: currentLng, - timestamp: baseTimestamp + (i * 60000), // Base: 1 minute intervals - id: baseTimestamp + i - }; - - // Use WebSocket simulation for more realistic testing - await simulateWebSocketMessage(page, pointData); - - // Wait for the random interval - await page.waitForTimeout(interval); - - // Log progress for longer streams - if (i % 5 === 0) { - console.log(`Streamed ${i + 1}/${pointCount} points`); - } - } -} - -/** - * Simulate real API-based point creation (most realistic but slower) - */ -async function simulateRealPointStream(page, pointData) { - // Get API key from the page - const apiKey = await page.evaluate(() => { - const mapElement = document.querySelector('#map'); - return mapElement?.dataset.api_key; - }); - - if (!apiKey) { - console.warn('API key not found, falling back to WebSocket simulation'); - return await simulateWebSocketMessage(page, pointData); - } - - // Create the point via API - const response = await page.evaluate(async (point, key) => { - try { - const response = await fetch('/api/v1/points', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${key}` - }, - body: JSON.stringify({ - point: { - latitude: point.lat, - longitude: point.lng, - timestamp: new Date(point.timestamp).toISOString(), - battery: 85, - altitude: 100, - velocity: 0 - } - }) - }); - - if (response.ok) { - return await response.json(); - } else { - console.error(`API call failed: ${response.status}`); - return null; - } - } catch (error) { - console.error('Error creating point via API:', error); - return null; - } - }, pointData, apiKey); - - if (response) { - // Wait for the WebSocket message to be processed - await page.waitForTimeout(200); - } else { - // Fallback to WebSocket simulation if API fails - await simulateWebSocketMessage(page, pointData); - } - - return response; -} diff --git a/e2e/map.spec.js b/e2e/map.spec.js deleted file mode 100644 index 1aac2601..00000000 --- a/e2e/map.spec.js +++ /dev/null @@ -1,1670 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * These tests cover the core features of the /map page - */ - -test.describe('Map Functionality', () => { - let page; - let context; - - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - page = await context.newPage(); - - // Sign in once for all tests - await page.goto('/users/sign_in'); - await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 }); - - await page.fill('input[name="user[email]"]', 'demo@dawarich.app'); - await page.fill('input[name="user[password]"]', 'password'); - await page.click('input[type="submit"][value="Log in"]'); - - // Wait for redirect to map page - await page.waitForURL('/map', { timeout: 10000 }); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - }); - - test.afterAll(async () => { - await page.close(); - await context.close(); - }); - - test.beforeEach(async () => { - await page.goto('/map'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - }); - - test.describe('Core Map Display', () => { - test('should initialize Leaflet map with functional container', async () => { - await expect(page).toHaveTitle(/Map/); - await expect(page.locator('#map')).toBeVisible(); - - // Wait for map to actually initialize (not just DOM presence) - await page.waitForFunction(() => { - const mapElement = document.querySelector('#map [data-maps-target="container"]'); - return mapElement && mapElement._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Verify map container is functional by checking for Leaflet instance - const hasLeafletInstance = await page.evaluate(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }); - expect(hasLeafletInstance).toBe(true); - }); - - test('should load and display map tiles with zoom functionality', async () => { - // Wait for map initialization - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }); - - // Check that tiles are actually loading (not just pane existence) - await page.waitForSelector('.leaflet-tile-pane img', { timeout: 10000 }); - - // Verify at least one tile has loaded - const tilesLoaded = await page.evaluate(() => { - const tiles = document.querySelectorAll('.leaflet-tile-pane img'); - return Array.from(tiles).some(tile => tile.complete && tile.naturalHeight > 0); - }); - expect(tilesLoaded).toBe(true); - - // Test zoom functionality by verifying zoom control interaction changes map state - const zoomInButton = page.locator('.leaflet-control-zoom-in'); - await expect(zoomInButton).toBeVisible(); - await expect(zoomInButton).toBeEnabled(); - - - // Click zoom in and verify it's clickable and responsive - await zoomInButton.click(); - await page.waitForTimeout(1000); // Wait for zoom animation - - // Verify zoom button is still functional (can be clicked again) - await expect(zoomInButton).toBeEnabled(); - - // Test zoom out works too - const zoomOutButton = page.locator('.leaflet-control-zoom-out'); - await expect(zoomOutButton).toBeVisible(); - await expect(zoomOutButton).toBeEnabled(); - - await zoomOutButton.click(); - await page.waitForTimeout(500); - }); - - test('should dynamically create functional scale control that updates with zoom', async () => { - // Wait for map initialization first (scale control is added after map setup) - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for scale control to be dynamically created by JavaScript - await page.waitForSelector('.leaflet-control-scale', { timeout: 10000 }); - - const scaleControl = page.locator('.leaflet-control-scale'); - await expect(scaleControl).toBeVisible(); - - // Verify scale control has proper structure (dynamically created) - const scaleLines = page.locator('.leaflet-control-scale-line'); - const scaleLineCount = await scaleLines.count(); - expect(scaleLineCount).toBeGreaterThan(0); // Should have at least one scale line - - // Get initial scale text to verify it contains actual measurements - const firstScaleLine = scaleLines.first(); - const initialScale = await firstScaleLine.textContent(); - expect(initialScale).toMatch(/\d+\s*(km|mi|m|ft)/); // Should contain distance units - - // Test functional behavior: zoom in and verify scale updates - const zoomInButton = page.locator('.leaflet-control-zoom-in'); - await expect(zoomInButton).toBeVisible(); - await zoomInButton.click(); - await page.waitForTimeout(1000); // Wait for zoom and scale update - - // Verify scale actually changed (proves it's functional, not static) - const newScale = await firstScaleLine.textContent(); - expect(newScale).not.toBe(initialScale); - expect(newScale).toMatch(/\d+\s*(km|mi|m|ft)/); // Should still be valid scale - - // Test zoom out to verify scale updates in both directions - const zoomOutButton = page.locator('.leaflet-control-zoom-out'); - await zoomOutButton.click(); - await page.waitForTimeout(1000); - - const finalScale = await firstScaleLine.textContent(); - expect(finalScale).not.toBe(newScale); // Should change again - expect(finalScale).toMatch(/\d+\s*(km|mi|m|ft)/); // Should be valid - }); - - test('should dynamically create functional stats control with processed data', async () => { - // Wait for map initialization first (stats control is added after map setup) - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for stats control to be dynamically created by JavaScript - await page.waitForSelector('.leaflet-control-stats', { timeout: 10000 }); - - const statsControl = page.locator('.leaflet-control-stats'); - await expect(statsControl).toBeVisible(); - - // Verify stats control displays properly formatted data (not static HTML) - const statsText = await statsControl.textContent(); - expect(statsText).toMatch(/\d+\s+(km|mi)\s+\|\s+\d+\s+points/); - - // Verify stats control has proper styling (applied by JavaScript) - const statsStyle = await statsControl.evaluate(el => { - const style = window.getComputedStyle(el); - return { - backgroundColor: style.backgroundColor, - padding: style.padding, - display: style.display - }; - }); - - expect(statsStyle.backgroundColor).toMatch(/rgb\(255,\s*255,\s*255\)|white/); // Should be white - expect(['inline-block', 'block']).toContain(statsStyle.display); // Should be block or inline-block - expect(statsStyle.padding).not.toBe('0px'); // Should have padding - - // Parse and validate the actual data content - const match = statsText.match(/(\d+)\s+(km|mi)\s+\|\s+(\d+)\s+points/); - expect(match).toBeTruthy(); // Should match the expected format - - if (match) { - const [, distance, unit, points] = match; - - // Verify distance is a valid number - const distanceNum = parseInt(distance); - expect(distanceNum).toBeGreaterThanOrEqual(0); - - // Verify unit is valid - expect(['km', 'mi']).toContain(unit); - - // Verify points is a valid number - const pointsNum = parseInt(points); - expect(pointsNum).toBeGreaterThanOrEqual(0); - - console.log(`Stats control displays: ${distance} ${unit} | ${points} points`); - } - - // Verify control positioning (should be in bottom right of map container) - const controlPosition = await statsControl.evaluate(el => { - const rect = el.getBoundingClientRect(); - const mapContainer = document.querySelector('#map [data-maps-target="container"]'); - const mapRect = mapContainer ? mapContainer.getBoundingClientRect() : null; - - return { - isBottomRight: mapRect ? - (rect.bottom <= mapRect.bottom + 10 && rect.right <= mapRect.right + 10) : - (rect.bottom > 0 && rect.right > 0), // Fallback if map container not found - isVisible: rect.width > 0 && rect.height > 0, - hasProperPosition: el.closest('.leaflet-bottom.leaflet-right') !== null - }; - }); - - expect(controlPosition.isVisible).toBe(true); - expect(controlPosition.isBottomRight).toBe(true); - expect(controlPosition.hasProperPosition).toBe(true); - }); - }); - - test.describe('Date and Time Navigation', () => { - test('should display date navigation controls and verify functionality', async () => { - // Check for date inputs - await expect(page.locator('input#start_at')).toBeVisible(); - await expect(page.locator('input#end_at')).toBeVisible(); - - // Verify date inputs are functional by checking they can be changed - const startDateInput = page.locator('input#start_at'); - const endDateInput = page.locator('input#end_at'); - - // Test that inputs can receive values (functional input fields) - await startDateInput.fill('2024-01-01T00:00'); - await expect(startDateInput).toHaveValue('2024-01-01T00:00'); - - await endDateInput.fill('2024-01-02T00:00'); - await expect(endDateInput).toHaveValue('2024-01-02T00:00'); - - // Check for navigation arrows and verify they have functional href attributes - const leftArrow = page.locator('a:has-text("◀️")'); - const rightArrow = page.locator('a:has-text("▶️")'); - - await expect(leftArrow).toBeVisible(); - await expect(rightArrow).toBeVisible(); - - // Verify arrows have functional href attributes (not just "#") - const leftHref = await leftArrow.getAttribute('href'); - const rightHref = await rightArrow.getAttribute('href'); - - expect(leftHref).toContain('start_at='); - expect(leftHref).toContain('end_at='); - expect(rightHref).toContain('start_at='); - expect(rightHref).toContain('end_at='); - - // Check for quick access buttons and verify they have functional links - const todayButton = page.locator('a:has-text("Today")'); - const last7DaysButton = page.locator('a:has-text("Last 7 days")'); - const lastMonthButton = page.locator('a:has-text("Last month")'); - - await expect(todayButton).toBeVisible(); - await expect(last7DaysButton).toBeVisible(); - await expect(lastMonthButton).toBeVisible(); - - // Verify quick access buttons have functional href attributes - const todayHref = await todayButton.getAttribute('href'); - const last7DaysHref = await last7DaysButton.getAttribute('href'); - const lastMonthHref = await lastMonthButton.getAttribute('href'); - - expect(todayHref).toContain('start_at='); - expect(todayHref).toContain('end_at='); - expect(last7DaysHref).toContain('start_at='); - expect(last7DaysHref).toContain('end_at='); - expect(lastMonthHref).toContain('start_at='); - expect(lastMonthHref).toContain('end_at='); - }); - - test('should allow changing date range and process form submission', async () => { - // Get initial URL to verify changes - const initialUrl = page.url(); - - const startDateInput = page.locator('input#start_at'); - const endDateInput = page.locator('input#end_at'); - - // Set specific test dates that are different from current values - const newStartDate = '2024-01-01T00:00'; - const newEndDate = '2024-01-31T23:59'; - - await startDateInput.fill(newStartDate); - await endDateInput.fill(newEndDate); - - // Verify form can accept the input values - await expect(startDateInput).toHaveValue(newStartDate); - await expect(endDateInput).toHaveValue(newEndDate); - - // Listen for navigation events to detect if form submission actually occurs - const navigationPromise = page.waitForURL(/start_at=2024-01-01/, { timeout: 5000 }); - - // Submit the form - await page.locator('input[type="submit"][value="Search"]').click(); - - // Wait for navigation to occur (if form submission works) - await navigationPromise; - - // Verify URL was actually updated with new parameters (form submission worked) - const newUrl = page.url(); - expect(newUrl).not.toBe(initialUrl); - expect(newUrl).toContain('start_at=2024-01-01'); - expect(newUrl).toContain('end_at=2024-01-31'); - - // Wait for page to be fully loaded - await page.waitForLoadState('networkidle'); - - // Verify the form inputs now reflect the submitted values after page reload - await expect(page.locator('input#start_at')).toHaveValue(newStartDate); - await expect(page.locator('input#end_at')).toHaveValue(newEndDate); - }); - - test('should navigate to today when clicking Today button', async () => { - await page.locator('a:has-text("Today")').click(); - await page.waitForLoadState('networkidle'); - - const url = page.url(); - // Allow for timezone differences by checking for current date or next day - const today = new Date().toISOString().split('T')[0]; - const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - expect(url.includes(today) || url.includes(tomorrow)).toBe(true); - }); - }); - - test.describe('Map Layer Controls', () => { - test('should dynamically create functional layer control panel', async () => { - // Wait for map initialization first (layer control is added after map setup) - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for layer control to be dynamically created by JavaScript - await page.waitForSelector('.leaflet-control-layers', { timeout: 10000 }); - - const layerControl = page.locator('.leaflet-control-layers'); - await expect(layerControl).toBeVisible(); - - // Verify layer control is functional by testing expand/collapse - await layerControl.click(); - await page.waitForTimeout(500); - - // Verify base layer section is dynamically created and functional - const baseLayerSection = page.locator('.leaflet-control-layers-base'); - await expect(baseLayerSection).toBeVisible(); - - // Verify base layer options are dynamically populated - const baseLayerInputs = baseLayerSection.locator('input[type="radio"]'); - const baseLayerCount = await baseLayerInputs.count(); - expect(baseLayerCount).toBeGreaterThan(0); // Should have at least one base layer - - // Verify overlay section is dynamically created and functional - const overlaySection = page.locator('.leaflet-control-layers-overlays'); - await expect(overlaySection).toBeVisible(); - - // Verify overlay options are dynamically populated - const overlayInputs = overlaySection.locator('input[type="checkbox"]'); - const overlayCount = await overlayInputs.count(); - expect(overlayCount).toBeGreaterThan(0); // Should have at least one overlay - - // Test that one base layer is selected (radio button behavior) - // Wait a moment for radio button states to stabilize - await page.waitForTimeout(1000); - - // Use evaluateAll instead of filter due to Playwright radio button filter issue - const radioStates = await baseLayerInputs.evaluateAll(inputs => - inputs.map(input => input.checked) - ); - - const checkedCount = radioStates.filter(checked => checked).length; - const totalCount = radioStates.length; - - console.log(`Base layer radios: ${totalCount} total, ${checkedCount} checked`); - - expect(checkedCount).toBe(1); // Exactly one base layer should be selected - }); - - test('should functionally toggle overlay layers with actual map effect', async () => { - // Wait for layer control to be dynamically created - await page.waitForSelector('.leaflet-control-layers', { timeout: 10000 }); - - const layerControl = page.locator('.leaflet-control-layers'); - await layerControl.click(); - await page.waitForTimeout(500); - - // Find any available overlay checkbox (not just Points, which might not exist) - const overlayCheckboxes = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]'); - const overlayCount = await overlayCheckboxes.count(); - - if (overlayCount > 0) { - const firstOverlay = overlayCheckboxes.first(); - const initialState = await firstOverlay.isChecked(); - - // Get the overlay name for testing - const overlayLabel = firstOverlay.locator('..'); - const overlayName = await overlayLabel.textContent(); - - // Test toggling functionality - await firstOverlay.click(); - await page.waitForTimeout(1000); // Wait for layer toggle to take effect - - // Verify checkbox state changed - const newState = await firstOverlay.isChecked(); - expect(newState).toBe(!initialState); - - // For specific layers, verify actual map effects - if (overlayName && overlayName.includes('Points')) { - // Test points layer visibility - const pointsCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); - - if (newState) { - // If enabled, should have markers (or 0 if no data) - expect(pointsCount).toBeGreaterThanOrEqual(0); - } else { - // If disabled, should have no markers - expect(pointsCount).toBe(0); - } - } - - // Toggle back to original state - await firstOverlay.click(); - await page.waitForTimeout(1000); - - // Verify it returns to original state - const finalState = await firstOverlay.isChecked(); - expect(finalState).toBe(initialState); - - } else { - // If no overlays available, at least verify layer control structure exists - await expect(page.locator('.leaflet-control-layers-overlays')).toBeVisible(); - console.log('No overlay layers found - skipping overlay toggle test'); - } - }); - - test('should functionally switch between base map layers with tile loading', async () => { - // Wait for layer control to be dynamically created - await page.waitForSelector('.leaflet-control-layers', { timeout: 10000 }); - - const layerControl = page.locator('.leaflet-control-layers'); - await layerControl.click(); - await page.waitForTimeout(500); - - // Find base layer radio buttons - const baseLayerRadios = page.locator('.leaflet-control-layers-base input[type="radio"]'); - const radioCount = await baseLayerRadios.count(); - - if (radioCount > 1) { - // Get initial state using evaluateAll to avoid Playwright filter bug - const radioStates = await baseLayerRadios.evaluateAll(inputs => - inputs.map((input, i) => ({ index: i, checked: input.checked, value: input.value })) - ); - - const initiallyCheckedIndex = radioStates.findIndex(r => r.checked); - const initiallyCheckedRadio = baseLayerRadios.nth(initiallyCheckedIndex); - const initialRadioValue = radioStates[initiallyCheckedIndex]?.value || '0'; - - // Find a different radio button to switch to - const targetIndex = radioStates.findIndex(r => !r.checked); - - if (targetIndex !== -1) { - const targetRadio = baseLayerRadios.nth(targetIndex); - const targetRadioValue = radioStates[targetIndex].value || '1'; - - // Switch to new base layer - await targetRadio.check(); - await page.waitForTimeout(3000); // Wait longer for tiles to load - - // Verify the switch was successful by re-evaluating radio states - const newRadioStates = await baseLayerRadios.evaluateAll(inputs => - inputs.map((input, i) => ({ index: i, checked: input.checked })) - ); - - expect(newRadioStates[targetIndex].checked).toBe(true); - expect(newRadioStates[initiallyCheckedIndex].checked).toBe(false); - - // Verify tile container exists (may not be visible but should be present) - const tilePane = page.locator('.leaflet-tile-pane'); - await expect(tilePane).toBeAttached(); - - // Verify tiles exist by checking for any tile-related elements - const hasMapTiles = await page.evaluate(() => { - const tiles = document.querySelectorAll('.leaflet-tile-pane img, .leaflet-tile'); - return tiles.length > 0; - }); - expect(hasMapTiles).toBe(true); - - // Switch back to original layer to verify toggle works both ways - await initiallyCheckedRadio.click(); - await page.waitForTimeout(2000); - - // Verify switch back was successful - const finalRadioStates = await baseLayerRadios.evaluateAll(inputs => - inputs.map((input, i) => ({ index: i, checked: input.checked })) - ); - - expect(finalRadioStates[initiallyCheckedIndex].checked).toBe(true); - expect(finalRadioStates[targetIndex].checked).toBe(false); - - } else { - console.log('Only one base layer available - skipping layer switch test'); - // At least verify the single layer is functional - const singleRadio = baseLayerRadios.first(); - await expect(singleRadio).toBeChecked(); - } - - } else { - console.log('No base layers found - this indicates a layer control setup issue'); - // Verify layer control structure exists even if no layers - await expect(page.locator('.leaflet-control-layers-base')).toBeVisible(); - } - }); - }); - - test.describe('Settings Panel', () => { - test('should create and interact with functional settings button', async () => { - // Wait for map initialization first (settings button is added after map setup) - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for settings button to be dynamically created by JavaScript - await page.waitForSelector('.map-settings-button', { timeout: 10000 }); - - const settingsButton = page.locator('.map-settings-button'); - await expect(settingsButton).toBeVisible(); - - // Verify it's actually a clickable button with gear icon - const buttonText = await settingsButton.textContent(); - expect(buttonText).toBe(''); - - // Test opening settings panel - await settingsButton.click(); - await page.waitForTimeout(500); // Wait for panel creation - - // Verify settings panel is dynamically created (not pre-existing) - const settingsPanel = page.locator('.leaflet-settings-panel'); - await expect(settingsPanel).toBeVisible(); - - const settingsForm = page.locator('#settings-form'); - await expect(settingsForm).toBeVisible(); - - // Verify form contains expected settings fields - await expect(page.locator('#route-opacity')).toBeVisible(); - await expect(page.locator('#fog_of_war_meters')).toBeVisible(); - await expect(page.locator('#raw')).toBeVisible(); - await expect(page.locator('#simplified')).toBeVisible(); - - // Test closing settings panel - await settingsButton.click(); - await page.waitForTimeout(500); - - // Panel should be removed from DOM (not just hidden) - const panelExists = await settingsPanel.count(); - expect(panelExists).toBe(0); - }); - - test('should functionally adjust route opacity through settings', async () => { - // Wait for map and settings to be initialized - await page.waitForSelector('.map-settings-button', { timeout: 10000 }); - - const settingsButton = page.locator('.map-settings-button'); - await settingsButton.click(); - await page.waitForTimeout(500); - - // Verify settings form is created dynamically - const opacityInput = page.locator('#route-opacity'); - await expect(opacityInput).toBeVisible(); - - // Get current value to ensure it's loaded - const currentValue = await opacityInput.inputValue(); - expect(currentValue).toMatch(/^\d+$/); // Should be a number - - // Change opacity to a specific test value - await opacityInput.fill('30'); - - // Verify input accepted the value - await expect(opacityInput).toHaveValue('30'); - - // Submit the form and verify it processes the submission - const submitButton = page.locator('#settings-form button[type="submit"]'); - await expect(submitButton).toBeVisible(); - await submitButton.click(); - - // Wait for form submission processing - await page.waitForTimeout(2000); - - // Check if panel closed after submission - const settingsModal = page.locator('#settings-modal, .settings-modal, [id*="settings"]'); - const isPanelClosed = await settingsModal.count() === 0 || - await settingsModal.isHidden().catch(() => true); - - console.log(`Settings panel closed after submission: ${isPanelClosed}`); - - // If panel didn't close, the form should still be visible - test persistence directly - if (!isPanelClosed) { - console.log('Panel stayed open after submission - testing persistence directly'); - // The form is still open, so we can check if the value persisted immediately - const persistedOpacityInput = page.locator('#route-opacity'); - await expect(persistedOpacityInput).toBeVisible(); - await expect(persistedOpacityInput).toHaveValue('30'); // Should still have our value - - // Test that we can change it again to verify form functionality - await persistedOpacityInput.fill('75'); - await expect(persistedOpacityInput).toHaveValue('75'); - - // Now close the panel manually for cleanup - const closeButton = page.locator('.modal-close, [data-bs-dismiss], .close, button:has-text("Close")'); - const closeButtonExists = await closeButton.count() > 0; - if (closeButtonExists) { - await closeButton.first().click(); - } else { - await page.keyboard.press('Escape'); - } - return; // Skip the reopen test since panel stayed open - } - - // Panel closed properly - verify settings were persisted by reopening settings - await settingsButton.click(); - await page.waitForTimeout(1000); - - const reopenedOpacityInput = page.locator('#route-opacity'); - await expect(reopenedOpacityInput).toBeVisible(); - await expect(reopenedOpacityInput).toHaveValue('30'); // Should match the value we set - - // Test that the form is actually functional by changing value again - await reopenedOpacityInput.fill('75'); - await expect(reopenedOpacityInput).toHaveValue('75'); - }); - - test('should functionally configure fog of war settings and verify form processing', async () => { - // Navigate to June 4, 2025 where we have data for fog of war testing - await page.goto(`${page.url().split('?')[0]}?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59`); - await page.waitForLoadState('networkidle'); - - // Wait for map and settings to be initialized - await page.waitForSelector('.map-settings-button', { timeout: 10000 }); - - const settingsButton = page.locator('.map-settings-button'); - await settingsButton.click(); - await page.waitForTimeout(500); - - // Verify settings form is dynamically created with fog settings - const fogRadiusInput = page.locator('#fog_of_war_meters'); - await expect(fogRadiusInput).toBeVisible(); - - const fogThresholdInput = page.locator('#fog_of_war_threshold'); - await expect(fogThresholdInput).toBeVisible(); - - // Get current values to ensure they're loaded from user settings - const currentRadius = await fogRadiusInput.inputValue(); - const currentThreshold = await fogThresholdInput.inputValue(); - expect(currentRadius).toMatch(/^\d+$/); // Should be a number - expect(currentThreshold).toMatch(/^\d+$/); // Should be a number - - // Change values to specific test values - await fogRadiusInput.fill('150'); - await fogThresholdInput.fill('180'); - - // Verify inputs accepted the values - await expect(fogRadiusInput).toHaveValue('150'); - await expect(fogThresholdInput).toHaveValue('180'); - - // Submit the form and verify it processes the submission - const submitButton = page.locator('#settings-form button[type="submit"]'); - await expect(submitButton).toBeVisible(); - await submitButton.click(); - - // Wait for form submission processing - await page.waitForTimeout(2000); - - // Check if panel closed after submission - const settingsModal = page.locator('#settings-modal, .settings-modal, [id*="settings"]'); - const isPanelClosed = await settingsModal.count() === 0 || - await settingsModal.isHidden().catch(() => true); - - console.log(`Fog settings panel closed after submission: ${isPanelClosed}`); - - // If panel didn't close, test persistence directly from the still-open form - if (!isPanelClosed) { - console.log('Fog panel stayed open after submission - testing persistence directly'); - const persistedFogRadiusInput = page.locator('#fog_of_war_meters'); - const persistedFogThresholdInput = page.locator('#fog_of_war_threshold'); - - await expect(persistedFogRadiusInput).toBeVisible(); - await expect(persistedFogThresholdInput).toBeVisible(); - await expect(persistedFogRadiusInput).toHaveValue('150'); - await expect(persistedFogThresholdInput).toHaveValue('180'); - - // Close panel for cleanup - const closeButton = page.locator('.modal-close, [data-bs-dismiss], .close, button:has-text("Close")'); - const closeButtonExists = await closeButton.count() > 0; - if (closeButtonExists) { - await closeButton.first().click(); - } else { - await page.keyboard.press('Escape'); - } - return; // Skip reopen test since panel stayed open - } - - // Panel closed properly - verify settings were persisted by reopening settings - await settingsButton.click(); - await page.waitForTimeout(1000); - - const reopenedFogRadiusInput = page.locator('#fog_of_war_meters'); - const reopenedFogThresholdInput = page.locator('#fog_of_war_threshold'); - - await expect(reopenedFogRadiusInput).toBeVisible(); - await expect(reopenedFogThresholdInput).toBeVisible(); - - // Verify values were persisted correctly - await expect(reopenedFogRadiusInput).toHaveValue('150'); - await expect(reopenedFogThresholdInput).toHaveValue('180'); - - // Test that the form is actually functional by changing values again - await reopenedFogRadiusInput.fill('200'); - await reopenedFogThresholdInput.fill('240'); - - await expect(reopenedFogRadiusInput).toHaveValue('200'); - await expect(reopenedFogThresholdInput).toHaveValue('240'); - }); - - test('should functionally enable fog of war layer and verify canvas creation', async () => { - // Wait for map initialization first - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Open layer control and wait for it to be functional - const layerControl = page.locator('.leaflet-control-layers'); - await expect(layerControl).toBeVisible(); - await layerControl.click(); - await page.waitForTimeout(500); - - // Find the Fog of War layer checkbox using multiple strategies - let fogCheckbox = page.locator('.leaflet-control-layers-overlays').locator('label:has-text("Fog of War")').locator('input'); - - // Fallback: try to find any checkbox associated with "Fog of War" text - if (!(await fogCheckbox.isVisible())) { - const allOverlayInputs = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]'); - const count = await allOverlayInputs.count(); - - for (let i = 0; i < count; i++) { - const checkbox = allOverlayInputs.nth(i); - const parentLabel = checkbox.locator('..'); - const labelText = await parentLabel.textContent(); - - if (labelText && labelText.includes('Fog of War')) { - fogCheckbox = checkbox; - break; - } - } - } - - // Verify fog functionality if fog layer is available - if (await fogCheckbox.isVisible()) { - const initiallyChecked = await fogCheckbox.isChecked(); - - // Ensure fog is initially disabled to test enabling - if (initiallyChecked) { - await fogCheckbox.uncheck(); - await page.waitForTimeout(1000); - await expect(page.locator('#fog')).not.toBeAttached(); - } - - // Enable fog of war and verify canvas creation - await fogCheckbox.check(); - await page.waitForTimeout(2000); // Wait for JavaScript to create fog canvas - - // Verify that fog canvas is actually created by JavaScript (not pre-existing) - await expect(page.locator('#fog')).toBeAttached(); - - const fogCanvas = page.locator('#fog'); - - // Verify canvas is functional with proper dimensions - const canvasBox = await fogCanvas.boundingBox(); - expect(canvasBox?.width).toBeGreaterThan(0); - expect(canvasBox?.height).toBeGreaterThan(0); - - // Verify canvas has correct styling for fog overlay - const canvasStyle = await fogCanvas.evaluate(el => { - const style = window.getComputedStyle(el); - return { - position: style.position, - zIndex: style.zIndex, - pointerEvents: style.pointerEvents - }; - }); - - expect(canvasStyle.position).toBe('absolute'); - expect(canvasStyle.zIndex).toBe('400'); - expect(canvasStyle.pointerEvents).toBe('none'); - - // Test toggle functionality - disable fog - await fogCheckbox.uncheck(); - await page.waitForTimeout(1000); - - // Canvas should be removed when layer is disabled - await expect(page.locator('#fog')).not.toBeAttached(); - - // Re-enable to verify toggle works both ways - await fogCheckbox.check(); - await page.waitForTimeout(1000); - - // Canvas should be recreated - await expect(page.locator('#fog')).toBeAttached(); - } else { - // If fog layer is not available, at least verify layer control is functional - await expect(page.locator('.leaflet-control-layers-overlays')).toBeVisible(); - console.log('Fog of War layer not found - skipping fog-specific tests'); - } - }); - - test('should functionally toggle points rendering mode and verify form processing', async () => { - // Navigate to June 4, 2025 where we have data for points rendering testing - await page.goto(`${page.url().split('?')[0]}?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59`); - await page.waitForLoadState('networkidle'); - - // Wait for map and settings to be initialized - await page.waitForSelector('.map-settings-button', { timeout: 10000 }); - - const settingsButton = page.locator('.map-settings-button'); - await settingsButton.click(); - await page.waitForTimeout(500); - - // Verify settings form is dynamically created with rendering mode options - const rawModeRadio = page.locator('#raw'); - const simplifiedModeRadio = page.locator('#simplified'); - - await expect(rawModeRadio).toBeVisible(); - await expect(simplifiedModeRadio).toBeVisible(); - - // Verify radio buttons are actually functional (one must be selected) - const rawChecked = await rawModeRadio.isChecked(); - const simplifiedChecked = await simplifiedModeRadio.isChecked(); - expect(rawChecked !== simplifiedChecked).toBe(true); // Exactly one should be checked - - const initiallyRaw = rawChecked; - - // Test toggling between modes - verify radio button behavior - if (initiallyRaw) { - // Switch to simplified mode - await simplifiedModeRadio.check(); - await expect(simplifiedModeRadio).toBeChecked(); - await expect(rawModeRadio).not.toBeChecked(); - } else { - // Switch to raw mode - await rawModeRadio.check(); - await expect(rawModeRadio).toBeChecked(); - await expect(simplifiedModeRadio).not.toBeChecked(); - } - - // Submit the form and verify it processes the submission - const submitButton = page.locator('#settings-form button[type="submit"]'); - await expect(submitButton).toBeVisible(); - await submitButton.click(); - - // Wait for form submission processing - await page.waitForTimeout(2000); - - // Check if panel closed after submission - const settingsModal = page.locator('#settings-modal, .settings-modal, [id*="settings"]'); - const isPanelClosed = await settingsModal.count() === 0 || - await settingsModal.isHidden().catch(() => true); - - console.log(`Points rendering panel closed after submission: ${isPanelClosed}`); - - // If panel didn't close, test persistence directly from the still-open form - if (!isPanelClosed) { - console.log('Points panel stayed open after submission - testing persistence directly'); - const persistedRawRadio = page.locator('#raw'); - const persistedSimplifiedRadio = page.locator('#simplified'); - - await expect(persistedRawRadio).toBeVisible(); - await expect(persistedSimplifiedRadio).toBeVisible(); - - // Verify the changed selection was persisted - if (initiallyRaw) { - await expect(persistedSimplifiedRadio).toBeChecked(); - await expect(persistedRawRadio).not.toBeChecked(); - } else { - await expect(persistedRawRadio).toBeChecked(); - await expect(persistedSimplifiedRadio).not.toBeChecked(); - } - - // Close panel for cleanup - const closeButton = page.locator('.modal-close, [data-bs-dismiss], .close, button:has-text("Close")'); - const closeButtonExists = await closeButton.count() > 0; - if (closeButtonExists) { - await closeButton.first().click(); - } else { - await page.keyboard.press('Escape'); - } - return; // Skip reopen test since panel stayed open - } - - // Panel closed properly - verify settings were persisted by reopening settings - await settingsButton.click(); - await page.waitForTimeout(1000); - - const reopenedRawRadio = page.locator('#raw'); - const reopenedSimplifiedRadio = page.locator('#simplified'); - - await expect(reopenedRawRadio).toBeVisible(); - await expect(reopenedSimplifiedRadio).toBeVisible(); - - // Verify the changed selection was persisted - if (initiallyRaw) { - await expect(reopenedSimplifiedRadio).toBeChecked(); - await expect(reopenedRawRadio).not.toBeChecked(); - } else { - await expect(reopenedRawRadio).toBeChecked(); - await expect(reopenedSimplifiedRadio).not.toBeChecked(); - } - - // Test that the form is still functional by toggling again - if (initiallyRaw) { - // Switch back to raw mode - await reopenedRawRadio.check(); - await expect(reopenedRawRadio).toBeChecked(); - await expect(reopenedSimplifiedRadio).not.toBeChecked(); - } else { - // Switch back to simplified mode - await reopenedSimplifiedRadio.check(); - await expect(reopenedSimplifiedRadio).toBeChecked(); - await expect(reopenedRawRadio).not.toBeChecked(); - } - }); - }); - - test.describe('Calendar Panel', () => { - test('should dynamically create functional calendar button and toggle panel', async () => { - // Wait for map initialization first (calendar button is added after map setup) - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for calendar button to be dynamically created by JavaScript - await page.waitForSelector('.toggle-panel-button', { timeout: 10000 }); - - const calendarButton = page.locator('.toggle-panel-button'); - await expect(calendarButton).toBeVisible(); - - // Verify it's actually a functional button with calendar icon - const buttonText = await calendarButton.textContent(); - expect(buttonText).toBe('📅'); - - // Ensure panel starts in closed state - await page.evaluate(() => localStorage.removeItem('mapPanelOpen')); - - // Verify panel doesn't exist initially (not pre-existing in DOM) - const initialPanelCount = await page.locator('.leaflet-right-panel').count(); - - // Click to open panel - triggers panel creation - await calendarButton.click(); - await page.waitForTimeout(2000); // Wait for JavaScript to create panel - - // Verify panel is dynamically created by JavaScript - const panel = page.locator('.leaflet-right-panel'); - await expect(panel).toBeAttached(); - - // Due to double-event issue causing toggling, force panel to be visible via JavaScript - await page.evaluate(() => { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - panel.style.display = 'block'; - localStorage.setItem('mapPanelOpen', 'true'); - console.log('Forced panel to be visible via JavaScript'); - } - }); - - // After forcing visibility, panel should be visible - await expect(panel).toBeVisible(); - - // Verify panel contains dynamically loaded content - await expect(panel.locator('#year-select')).toBeVisible(); - await expect(panel.locator('#months-grid')).toBeVisible(); - - // Test closing functionality - force panel to be hidden due to double-event issue - await page.evaluate(() => { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - panel.style.display = 'none'; - localStorage.setItem('mapPanelOpen', 'false'); - console.log('Forced panel to be hidden via JavaScript'); - } - }); - - // Panel should be hidden (but may still exist in DOM for performance) - const finalVisible = await panel.isVisible(); - expect(finalVisible).toBe(false); - - // Test toggle functionality works both ways - force panel to be visible again - await page.evaluate(() => { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - panel.style.display = 'block'; - localStorage.setItem('mapPanelOpen', 'true'); - console.log('Forced panel to be visible again via JavaScript'); - } - }); - await expect(panel).toBeVisible(); - }); - - test('should dynamically load functional year selection and months grid', async () => { - // Wait for map initialization first - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for calendar button to be dynamically created - await page.waitForSelector('.toggle-panel-button', { timeout: 10000 }); - - const calendarButton = page.locator('.toggle-panel-button'); - - // Ensure panel starts closed and clean up any previous state - await page.evaluate(() => { - localStorage.removeItem('mapPanelOpen'); - // Remove any existing panel - const existingPanel = document.querySelector('.leaflet-right-panel'); - if (existingPanel) { - existingPanel.remove(); - } - }); - - // Open panel - click to trigger panel creation - await calendarButton.click(); - await page.waitForTimeout(2000); // Wait for panel creation - - const panel = page.locator('.leaflet-right-panel'); - await expect(panel).toBeAttached(); - - // Due to double-event issue causing toggling, force panel to be visible via JavaScript - await page.evaluate(() => { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - panel.style.display = 'block'; - localStorage.setItem('mapPanelOpen', 'true'); - console.log('Forced panel to be visible for year/months test'); - } - }); - - await expect(panel).toBeVisible(); - - // Verify year selector is dynamically created and functional - const yearSelect = page.locator('#year-select'); - await expect(yearSelect).toBeVisible(); - - // Verify it's a functional select element with options - const yearOptions = yearSelect.locator('option'); - const optionCount = await yearOptions.count(); - expect(optionCount).toBeGreaterThan(0); - - // Verify months grid is dynamically created - const monthsGrid = page.locator('#months-grid'); - await expect(monthsGrid).toBeVisible(); - - // Wait for async API call to complete and replace loading state - // Initially shows loading dots, then real month buttons after API response - await page.waitForFunction(() => { - const grid = document.querySelector('#months-grid'); - if (!grid) return false; - - // Check if loading dots are gone and real month buttons are present - const loadingDots = grid.querySelectorAll('.loading-dots'); - const monthButtons = grid.querySelectorAll('a[data-month-name]'); - - return loadingDots.length === 0 && monthButtons.length > 0; - }, { timeout: 10000 }); - - console.log('Months grid loaded successfully after API call'); - - // Verify month buttons are dynamically created (not static HTML) - const monthButtons = monthsGrid.locator('a.btn'); - const monthCount = await monthButtons.count(); - expect(monthCount).toBeGreaterThan(0); - expect(monthCount).toBeLessThanOrEqual(12); - - // Verify month buttons are functional with proper href attributes - for (let i = 0; i < Math.min(monthCount, 3); i++) { - const monthButton = monthButtons.nth(i); - await expect(monthButton).toHaveAttribute('href'); - - // Verify href contains date parameters (indicates dynamic generation) - const href = await monthButton.getAttribute('href'); - expect(href).toMatch(/start_at=|end_at=/); - } - - // Verify whole year link is dynamically created and functional - const wholeYearLink = page.locator('#whole-year-link'); - await expect(wholeYearLink).toBeVisible(); - await expect(wholeYearLink).toHaveAttribute('href'); - - const wholeYearHref = await wholeYearLink.getAttribute('href'); - expect(wholeYearHref).toMatch(/start_at=|end_at=/); - }); - - test('should dynamically load visited cities section with functional content', async () => { - // Wait for calendar button to be dynamically created - await page.waitForSelector('.toggle-panel-button', { timeout: 10000 }); - - const calendarButton = page.locator('.toggle-panel-button'); - - // Ensure panel starts closed - await page.evaluate(() => localStorage.removeItem('mapPanelOpen')); - - // Open panel and verify content is dynamically loaded - await calendarButton.click(); - await page.waitForTimeout(2000); - - const panel = page.locator('.leaflet-right-panel'); - await expect(panel).toBeAttached(); - - // Due to double-event issue causing toggling, force panel to be visible via JavaScript - await page.evaluate(() => { - const panel = document.querySelector('.leaflet-right-panel'); - if (panel) { - panel.style.display = 'block'; - localStorage.setItem('mapPanelOpen', 'true'); - console.log('Forced panel to be visible for visited cities test'); - } - }); - - await expect(panel).toBeVisible(); - - // Verify visited cities container is dynamically created - const citiesContainer = page.locator('#visited-cities-container'); - await expect(citiesContainer).toBeVisible(); - - // Verify cities list container is dynamically created - const citiesList = page.locator('#visited-cities-list'); - await expect(citiesList).toBeVisible(); - - // Verify the container has proper structure for dynamic content - const containerClass = await citiesContainer.getAttribute('class'); - expect(containerClass).toBeTruthy(); - - const listId = await citiesList.getAttribute('id'); - expect(listId).toBe('visited-cities-list'); - - // Test that the container is ready to receive dynamic city data - // (cities may be empty in test environment, but structure should be functional) - const cityItems = citiesList.locator('> *'); - const cityCount = await cityItems.count(); - - // If cities exist, verify they have functional structure - if (cityCount > 0) { - const firstCity = cityItems.first(); - await expect(firstCity).toBeVisible(); - - // Verify city items are clickable links (not static text) - const isLink = await firstCity.evaluate(el => el.tagName.toLowerCase() === 'a'); - if (isLink) { - await expect(firstCity).toHaveAttribute('href'); - } - } - - // Verify section header exists and is properly structured - const sectionHeaders = panel.locator('h3, h4, .section-title'); - const headerCount = await sectionHeaders.count(); - expect(headerCount).toBeGreaterThan(0); // Should have at least one section header - }); - }); - - test.describe('Visits System', () => { - test('should have visits drawer button', async () => { - const visitsButton = page.locator('.drawer-button'); - await expect(visitsButton).toBeVisible(); - }); - - test('should open and close visits drawer', async () => { - const visitsButton = page.locator('.drawer-button'); - await visitsButton.click(); - - // Check that visits drawer opens - await expect(page.locator('#visits-drawer')).toBeVisible(); - await expect(page.locator('#visits-list')).toBeVisible(); - - // Close drawer - await visitsButton.click(); - - // Drawer should slide closed (but element might still be in DOM) - await page.waitForTimeout(500); - }); - - test('should have area selection tool button', async () => { - const selectionButton = page.locator('#selection-tool-button'); - await expect(selectionButton).toBeVisible(); - await expect(selectionButton).toHaveText('⚓️'); - }); - - test('should activate selection mode', async () => { - const selectionButton = page.locator('#selection-tool-button'); - await selectionButton.click(); - - // Button should become active - await expect(selectionButton).toHaveClass(/active/); - - // Click again to deactivate - await selectionButton.click(); - - // Button should no longer be active - await expect(selectionButton).not.toHaveClass(/active/); - }); - }); - - test.describe('Interactive Map Elements', () => { - test('should provide functional zoom controls and responsive map interaction', async () => { - // Wait for map initialization first (zoom controls are created with map) - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for zoom controls to be dynamically created - await page.waitForSelector('.leaflet-control-zoom', { timeout: 10000 }); - - const mapContainer = page.locator('.leaflet-container'); - await expect(mapContainer).toBeVisible(); - - // Verify zoom controls are dynamically created and functional - const zoomInButton = page.locator('.leaflet-control-zoom-in'); - const zoomOutButton = page.locator('.leaflet-control-zoom-out'); - - await expect(zoomInButton).toBeVisible(); - await expect(zoomOutButton).toBeVisible(); - - // Test functional zoom in behavior with scale validation - const scaleControl = page.locator('.leaflet-control-scale-line').first(); - const initialScale = await scaleControl.textContent(); - - await zoomInButton.click(); - await page.waitForTimeout(1000); // Wait for zoom animation and scale update - - // Verify zoom actually changed the scale (proves functionality) - const newScale = await scaleControl.textContent(); - expect(newScale).not.toBe(initialScale); - - // Test zoom out functionality - await zoomOutButton.click(); - await page.waitForTimeout(1000); - - const finalScale = await scaleControl.textContent(); - expect(finalScale).not.toBe(newScale); // Should change again - - // Test map interactivity by performing drag operation - await mapContainer.hover(); - await page.mouse.down(); - await page.mouse.move(100, 100); - await page.mouse.up(); - await page.waitForTimeout(500); - - // Verify map container is interactive (has Leaflet ID and responds to interaction) - const mapInteractive = await page.evaluate(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && - container._leaflet_id !== undefined && - container.classList.contains('leaflet-container'); - }); - - expect(mapInteractive).toBe(true); - }); - - test('should dynamically render functional markers with interactive popups', async () => { - // Wait for map initialization - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for marker pane to be created by Leaflet - await page.waitForSelector('.leaflet-marker-pane', { timeout: 10000, state: 'attached' }); - - const markerPane = page.locator('.leaflet-marker-pane'); - await expect(markerPane).toBeAttached(); // Pane should exist even if no markers - - // Check for dynamically created markers - const markers = page.locator('.leaflet-marker-pane .leaflet-marker-icon'); - const markerCount = await markers.count(); - - if (markerCount > 0) { - // Test first marker functionality - const firstMarker = markers.first(); - await expect(firstMarker).toBeVisible(); - - // Verify marker has proper Leaflet attributes (dynamic creation) - const markerStyle = await firstMarker.evaluate(el => { - return { - hasTransform: el.style.transform !== '', - hasZIndex: el.style.zIndex !== '', - isPositioned: window.getComputedStyle(el).position === 'absolute' - }; - }); - - expect(markerStyle.hasTransform).toBe(true); // Leaflet positions with transform - expect(markerStyle.isPositioned).toBe(true); - - // Test marker click functionality - await firstMarker.click(); - await page.waitForTimeout(1000); - - // Check if popup was dynamically created and displayed - const popup = page.locator('.leaflet-popup'); - const popupExists = await popup.count() > 0; - - if (popupExists) { - await expect(popup).toBeVisible(); - - // Verify popup has content (not empty) - const popupContent = page.locator('.leaflet-popup-content'); - await expect(popupContent).toBeVisible(); - - const contentText = await popupContent.textContent(); - expect(contentText).toBeTruthy(); // Should have some content - - // Test popup close functionality - const closeButton = page.locator('.leaflet-popup-close-button'); - if (await closeButton.isVisible()) { - await closeButton.click(); - await page.waitForTimeout(500); - - // Popup should be removed/hidden - const popupStillVisible = await popup.isVisible(); - expect(popupStillVisible).toBe(false); - } - } else { - console.log('No popup functionality available - testing marker presence only'); - } - } else { - console.log('No markers found in current date range - testing marker pane structure'); - // Even without markers, marker pane should exist - await expect(markerPane).toBeAttached(); - } - }); - - test('should dynamically render functional routes with interactive styling', async () => { - // Wait for map initialization - await page.waitForFunction(() => { - const container = document.querySelector('#map [data-maps-target="container"]'); - return container && container._leaflet_id !== undefined; - }, { timeout: 10000 }); - - // Wait for overlay pane to be created by Leaflet - await page.waitForSelector('.leaflet-overlay-pane', { timeout: 10000, state: 'attached' }); - - const overlayPane = page.locator('.leaflet-overlay-pane'); - await expect(overlayPane).toBeAttached(); // Pane should exist even if no routes - - // Check for dynamically created SVG elements (routes/polylines) - const svgContainer = overlayPane.locator('svg'); - const svgExists = await svgContainer.count() > 0; - - if (svgExists) { - await expect(svgContainer).toBeVisible(); - - // Verify SVG has proper Leaflet attributes (dynamic creation) - const svgAttributes = await svgContainer.evaluate(el => { - return { - hasViewBox: el.hasAttribute('viewBox'), - hasPointerEvents: el.style.pointerEvents !== '', - isPositioned: window.getComputedStyle(el).position !== 'static' - }; - }); - - expect(svgAttributes.hasViewBox).toBe(true); - - // Check for path elements (actual route lines) - const polylines = svgContainer.locator('path'); - const polylineCount = await polylines.count(); - - if (polylineCount > 0) { - const firstPolyline = polylines.first(); - await expect(firstPolyline).toBeVisible(); - - // Verify polyline has proper styling (dynamic creation) - const pathAttributes = await firstPolyline.evaluate(el => { - return { - hasStroke: el.hasAttribute('stroke'), - hasStrokeWidth: el.hasAttribute('stroke-width'), - hasD: el.hasAttribute('d') && el.getAttribute('d').length > 0, - strokeColor: el.getAttribute('stroke') - }; - }); - - expect(pathAttributes.hasStroke).toBe(true); - expect(pathAttributes.hasStrokeWidth).toBe(true); - expect(pathAttributes.hasD).toBe(true); // Should have path data - expect(pathAttributes.strokeColor).toBeTruthy(); - - // Test polyline hover interaction - await firstPolyline.hover(); - await page.waitForTimeout(500); - - // Verify hover doesn't break the element - await expect(firstPolyline).toBeVisible(); - - } else { - console.log('No polylines found in current date range - SVG container exists'); - } - } else { - console.log('No SVG container found - testing overlay pane structure'); - // Even without routes, overlay pane should exist - await expect(overlayPane).toBeAttached(); - } - }); - }); - - test.describe('Areas Management', () => { - test('should have draw control when areas layer is active', async () => { - // Open layer control - const layerControl = page.locator('.leaflet-control-layers'); - await layerControl.click(); - - // Find and enable Areas layer - const areasCheckbox = page.locator('.leaflet-control-layers-overlays').locator('input').filter({ hasText: /Areas/ }).first(); - - if (await areasCheckbox.isVisible()) { - await areasCheckbox.check(); - - // Check for draw control - await expect(page.locator('.leaflet-draw')).toBeVisible(); - - // Check for circle draw tool - await expect(page.locator('.leaflet-draw-draw-circle')).toBeVisible(); - } - }); - }); - - test.describe('Performance and Loading', () => { - test('should load within reasonable time', async () => { - const startTime = Date.now(); - - await page.goto('/map'); - await page.waitForSelector('.leaflet-container', { timeout: 15000 }); - - const loadTime = Date.now() - startTime; - expect(loadTime).toBeLessThan(15000); // Should load within 15 seconds - }); - - test('should handle network errors gracefully', async () => { - // Should still show the page structure even if tiles don't load - await expect(page.locator('#map')).toBeVisible(); - - // Test with offline network after initial load - await page.context().setOffline(true); - - // Page should still be functional even when offline - await expect(page.locator('.leaflet-container')).toBeVisible(); - - // Restore network - await page.context().setOffline(false); - }); - }); - - test.describe('Responsive Design', () => { - test('should adapt to mobile viewport', async () => { - // Set mobile viewport - await page.setViewportSize({ width: 375, height: 667 }); - - await page.goto('/map'); - await page.waitForSelector('.leaflet-container'); - - // Map should still be visible and functional - await expect(page.locator('.leaflet-container')).toBeVisible(); - await expect(page.locator('.leaflet-control-zoom')).toBeVisible(); - - // Date controls should be responsive - await expect(page.locator('input#start_at')).toBeVisible(); - await expect(page.locator('input#end_at')).toBeVisible(); - }); - - test('should work on tablet viewport', async () => { - // Set tablet viewport - await page.setViewportSize({ width: 768, height: 1024 }); - - await page.goto('/map'); - await page.waitForSelector('.leaflet-container'); - - await expect(page.locator('.leaflet-container')).toBeVisible(); - await expect(page.locator('.leaflet-control-layers')).toBeVisible(); - }); - }); - - test.describe('Accessibility', () => { - test('should have proper accessibility attributes', async () => { - // Check for map container accessibility - const mapContainer = page.locator('#map'); - await expect(mapContainer).toHaveAttribute('data-controller', 'maps points'); - - // Check form labels - await expect(page.locator('label[for="start_at"]')).toBeVisible(); - await expect(page.locator('label[for="end_at"]')).toBeVisible(); - - // Check button accessibility - const searchButton = page.locator('input[type="submit"][value="Search"]'); - await expect(searchButton).toBeVisible(); - }); - - test('should support keyboard navigation', async () => { - // Test tab navigation through form elements - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - await page.keyboard.press('Tab'); - - // Should be able to focus on interactive elements - const focusedElement = page.locator(':focus'); - await expect(focusedElement).toBeVisible(); - }); - }); - - test.describe('Data Integration', () => { - test('should handle empty data state', async () => { - // Navigate to a date range with no data - await page.goto('/map?start_at=1990-01-01T00:00&end_at=1990-01-02T00:00'); - await page.waitForSelector('.leaflet-container'); - - // Map should still load - await expect(page.locator('.leaflet-container')).toBeVisible(); - - // Stats should show zero - const statsControl = page.locator('.leaflet-control-stats'); - if (await statsControl.isVisible()) { - const statsText = await statsControl.textContent(); - expect(statsText).toContain('0'); - } - }); - - test('should update URL parameters when navigating', async () => { - const initialUrl = page.url(); - - // Click on a navigation arrow - await page.locator('a:has-text("▶️")').click(); - await page.waitForLoadState('networkidle'); - - const newUrl = page.url(); - expect(newUrl).not.toBe(initialUrl); - expect(newUrl).toContain('start_at='); - expect(newUrl).toContain('end_at='); - }); - }); - - test.describe('Error Handling', () => { - test('should display error messages for invalid date ranges and handle gracefully', async () => { - // Listen for console errors to verify error logging - const consoleErrors = []; - page.on('console', message => { - if (message.type() === 'error') { - consoleErrors.push(message.text()); - } - }); - - // Get initial URL to compare after invalid date submission - const initialUrl = page.url(); - - // Try to set end date before start date (invalid range) - await page.locator('input#start_at').fill('2024-12-31T23:59'); - await page.locator('input#end_at').fill('2024-01-01T00:00'); - - await page.locator('input[type="submit"][value="Search"]').click(); - await page.waitForLoadState('networkidle'); - - // Verify the application handles the error gracefully - await expect(page.locator('.leaflet-container')).toBeVisible(); - - // Check for actual error handling behavior: - // 1. Look for error messages in the UI - const errorMessages = page.locator('.alert, .error, [class*="error"], .flash, .notice'); - const errorCount = await errorMessages.count(); - - // 2. Check if dates were corrected/handled - const finalUrl = page.url(); - const urlChanged = finalUrl !== initialUrl; - - // 3. Verify the form inputs reflect the handling (either corrected or reset) - const startValue = await page.locator('input#start_at').inputValue(); - const endValue = await page.locator('input#end_at').inputValue(); - - // Error handling should either: - // - Show an error message to the user, OR - // - Automatically correct the invalid date range, OR - // - Prevent the invalid submission and keep original values - const hasErrorFeedback = errorCount > 0; - const datesWereCorrected = urlChanged && new Date(startValue) <= new Date(endValue); - const submissionWasPrevented = !urlChanged; - - // For now, we expect graceful handling even if no explicit error message is shown - // The main requirement is that the application doesn't crash and remains functional - const applicationRemainsStable = true; // Map container is visible and functional - expect(applicationRemainsStable).toBe(true); - - // Verify the map still functions after error handling - await expect(page.locator('.leaflet-control-layers')).toBeVisible(); - }); - - test('should handle JavaScript errors gracefully and verify error recovery', async () => { - // Listen for console errors to verify error logging occurs - const consoleErrors = []; - page.on('console', message => { - if (message.type() === 'error') { - consoleErrors.push(message.text()); - } - }); - - // Listen for unhandled errors that might break the page - const pageErrors = []; - page.on('pageerror', error => { - pageErrors.push(error.message); - }); - - await page.goto('/map'); - await page.waitForSelector('.leaflet-container'); - - // Inject invalid data to trigger error handling in the maps controller - await page.evaluate(() => { - // Try to trigger a JSON parsing error by corrupting data - const mapElement = document.getElementById('map'); - if (mapElement) { - // Set invalid JSON data that should trigger error handling - mapElement.setAttribute('data-coordinates', '{"invalid": json}'); - mapElement.setAttribute('data-user_settings', 'not valid json at all'); - - // Try to trigger the controller to re-parse this data - if (mapElement._stimulus_controllers) { - const controller = mapElement._stimulus_controllers.find(c => c.identifier === 'maps'); - if (controller) { - // This should trigger the try/catch error handling - try { - JSON.parse('{"invalid": json}'); - } catch (e) { - console.error('Test error:', e.message); - } - } - } - } - }); - - // Wait a moment for any error handling to occur - await page.waitForTimeout(1000); - - // Verify map still functions despite errors - this shows error recovery - await expect(page.locator('.leaflet-container')).toBeVisible(); - - // Verify error handling mechanisms are working by checking for console errors - // (We expect some errors from our invalid data injection) - const hasConsoleErrors = consoleErrors.length > 0; - - // Critical functionality should still work after error recovery - const layerControl = page.locator('.leaflet-control-layers'); - await expect(layerControl).toBeVisible(); - - // Settings button should be functional after error recovery - const settingsButton = page.locator('.map-settings-button'); - await expect(settingsButton).toBeVisible(); - - // Test that interactions still work after error handling - await layerControl.click(); - await expect(page.locator('.leaflet-control-layers-list')).toBeVisible(); - - // Allow some page errors from our intentional invalid data injection - // The key is that the application handles them gracefully and keeps working - const applicationHandledErrorsGracefully = pageErrors.length < 5; // Some errors expected but not too many - expect(applicationHandledErrorsGracefully).toBe(true); - - // The application should log errors (showing error handling is active) - // but continue functioning (showing graceful recovery) - console.log(`Console errors detected: ${consoleErrors.length}`); - }); - }); -}); diff --git a/e2e/map/map-add-visit.spec.js b/e2e/map/map-add-visit.spec.js new file mode 100644 index 00000000..485642ee --- /dev/null +++ b/e2e/map/map-add-visit.spec.js @@ -0,0 +1,260 @@ +import { test, expect } from '@playwright/test'; +import { navigateToMap } from '../helpers/navigation.js'; +import { waitForMap } from '../helpers/map.js'; + +/** + * Helper to wait for add visit controller to be fully initialized + */ +async function waitForAddVisitController(page) { + await page.waitForTimeout(2000); // Wait for controller to connect and attach handlers +} + +test.describe('Add Visit Control', () => { + test.beforeEach(async ({ page }) => { + await navigateToMap(page); + await waitForMap(page); + await waitForAddVisitController(page); + }); + + test('should show add visit button control', async ({ page }) => { + const addVisitButton = page.locator('.add-visit-button'); + await expect(addVisitButton).toBeVisible(); + }); + + test('should enable add visit mode when clicked', async ({ page }) => { + const addVisitButton = page.locator('.add-visit-button'); + await addVisitButton.click(); + await page.waitForTimeout(1000); + + // Verify flash message appears + const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Click on the map")'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // Verify cursor changed to crosshair + const cursor = await page.evaluate(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container?.style.cursor; + }); + expect(cursor).toBe('crosshair'); + + // Verify button has active state (background color applied) + const hasActiveStyle = await addVisitButton.evaluate((el) => { + return el.style.backgroundColor !== ''; + }); + expect(hasActiveStyle).toBe(true); + }); + + test('should open popup form when map is clicked', async ({ page }) => { + const addVisitButton = page.locator('.add-visit-button'); + await addVisitButton.click(); + await page.waitForTimeout(500); + + // Click on map - use bottom left corner which is less likely to have points + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8); + await page.waitForTimeout(1000); + + // Verify popup is visible + const popup = page.locator('.leaflet-popup'); + await expect(popup).toBeVisible({ timeout: 10000 }); + + // Verify popup contains the add visit form + await expect(popup.locator('h3:has-text("Add New Visit")')).toBeVisible(); + + // Verify marker appears (📍 emoji with class add-visit-marker) + const marker = page.locator('.add-visit-marker'); + await expect(marker).toBeVisible(); + }); + + test('should display correct form content in popup', async ({ page }) => { + // Enable mode and click map + await page.locator('.add-visit-button').click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8); + await page.waitForTimeout(1000); + + // Verify popup content has all required elements + const popupContent = page.locator('.leaflet-popup-content'); + await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible(); + await expect(popupContent.locator('input#visit-name')).toBeVisible(); + await expect(popupContent.locator('input#visit-start')).toBeVisible(); + await expect(popupContent.locator('input#visit-end')).toBeVisible(); + await expect(popupContent.locator('button:has-text("Create Visit")')).toBeVisible(); + await expect(popupContent.locator('button:has-text("Cancel")')).toBeVisible(); + + // Verify name field has focus + const nameFieldFocused = await page.evaluate(() => { + return document.activeElement?.id === 'visit-name'; + }); + expect(nameFieldFocused).toBe(true); + + // Verify start and end time have default values + const startValue = await page.locator('input#visit-start').inputValue(); + const endValue = await page.locator('input#visit-end').inputValue(); + expect(startValue).toBeTruthy(); + expect(endValue).toBeTruthy(); + }); + + test('should hide popup and remove marker when cancel is clicked', async ({ page }) => { + // Enable mode and click map + await page.locator('.add-visit-button').click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8); + await page.waitForTimeout(1000); + + // Verify popup and marker exist + await expect(page.locator('.leaflet-popup')).toBeVisible(); + await expect(page.locator('.add-visit-marker')).toBeVisible(); + + // Click cancel button + await page.locator('#cancel-visit').click(); + await page.waitForTimeout(500); + + // Verify popup is hidden + const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false); + expect(popupVisible).toBe(false); + + // Verify marker is removed + const markerCount = await page.locator('.add-visit-marker').count(); + expect(markerCount).toBe(0); + + // Verify cursor is reset to default + const cursor = await page.evaluate(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container?.style.cursor; + }); + expect(cursor).toBe(''); + + // Verify mode was exited (cursor should be reset) + const cursorReset = await page.evaluate(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container?.style.cursor === ''; + }); + expect(cursorReset).toBe(true); + }); + + test('should create visit and show marker on map when submitted', async ({ page }) => { + // Get initial confirmed visit count + const initialCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.confirmedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length; + } + return 0; + }); + + // Enable mode and click map + await page.locator('.add-visit-button').click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8); + await page.waitForTimeout(1000); + + // Fill form with unique visit name + const visitName = `E2E Test Visit ${Date.now()}`; + await page.locator('#visit-name').fill(visitName); + + // Submit form + await page.locator('button:has-text("Create Visit")').click(); + await page.waitForTimeout(2000); + + // Verify success message + const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("created successfully")'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // Verify popup is closed + const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false); + expect(popupVisible).toBe(false); + + // Verify confirmed visit marker count increased + const finalCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.confirmedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length; + } + return 0; + }); + + expect(finalCount).toBeGreaterThan(initialCount); + }); + + test('should disable add visit mode when clicked second time', async ({ page }) => { + const addVisitButton = page.locator('.add-visit-button'); + + // First click - enable mode + await addVisitButton.click(); + await page.waitForTimeout(500); + + // Verify mode is enabled + const cursorEnabled = await page.evaluate(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container?.style.cursor === 'crosshair'; + }); + expect(cursorEnabled).toBe(true); + + // Second click - disable mode + await addVisitButton.click(); + await page.waitForTimeout(500); + + // Verify cursor is reset + const cursorDisabled = await page.evaluate(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container?.style.cursor; + }); + expect(cursorDisabled).toBe(''); + + // Verify mode was exited by checking if we can click map without creating marker + const isAddingVisit = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'add-visit'); + return controller?.isAddingVisit === true; + }); + expect(isAddingVisit).toBe(false); + }); + + test('should ensure only one visit popup is open at a time', async ({ page }) => { + const addVisitButton = page.locator('.add-visit-button'); + await addVisitButton.click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + + // Click first location on map + await page.mouse.click(bbox.x + bbox.width * 0.3, bbox.y + bbox.height * 0.3); + await page.waitForTimeout(500); + + // Verify first popup exists + let popupCount = await page.locator('.leaflet-popup').count(); + expect(popupCount).toBe(1); + + // Get the content of first popup to verify it exists + const firstPopupContent = await page.locator('.leaflet-popup-content input#visit-name').count(); + expect(firstPopupContent).toBe(1); + + // Click second location on map + await page.mouse.click(bbox.x + bbox.width * 0.7, bbox.y + bbox.height * 0.7); + await page.waitForTimeout(500); + + // Verify still only one popup exists (old one was closed, new one opened) + popupCount = await page.locator('.leaflet-popup').count(); + expect(popupCount).toBe(1); + + // Verify the popup contains the add visit form (not some other popup) + const popupContent = page.locator('.leaflet-popup-content'); + await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible(); + await expect(popupContent.locator('input#visit-name')).toBeVisible(); + + // Verify only one marker exists + const markerCount = await page.locator('.add-visit-marker').count(); + expect(markerCount).toBe(1); + }); +}); diff --git a/e2e/map/map-bulk-delete.spec.js b/e2e/map/map-bulk-delete.spec.js new file mode 100644 index 00000000..4e5ef48a --- /dev/null +++ b/e2e/map/map-bulk-delete.spec.js @@ -0,0 +1,380 @@ +import { test, expect } from '@playwright/test'; +import { drawSelectionRectangle } from '../helpers/selection.js'; +import { navigateToDate, closeOnboardingModal } from '../helpers/navigation.js'; +import { waitForMap, enableLayer } from '../helpers/map.js'; + +test.describe('Bulk Delete Points', () => { + test.beforeEach(async ({ page }) => { + // Navigate to map page + await page.goto('/map', { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + + // Wait for map to be initialized + await waitForMap(page); + + // Close onboarding modal if present + await closeOnboardingModal(page); + + // Navigate to a date with points (October 13, 2024) + await navigateToDate(page, '2024-10-13T00:00', '2024-10-13T23:59'); + + // Enable Points layer + await enableLayer(page, 'Points'); + }); + + test('should show area selection tool button', async ({ page }) => { + // Check that area selection button exists + const selectionButton = page.locator('#selection-tool-button'); + await expect(selectionButton).toBeVisible(); + }); + + test('should enable selection mode when area tool is clicked', async ({ page }) => { + // Click area selection button + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + // Verify selection mode is active + const isSelectionActive = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.selectionMode === true; + }); + + expect(isSelectionActive).toBe(true); + }); + + test('should select points in drawn area and show delete button', async ({ page }) => { + await drawSelectionRectangle(page); + + // Check that delete button appears + const deleteButton = page.locator('#delete-selection-button'); + await expect(deleteButton).toBeVisible({ timeout: 10000 }); + + // Check button has text "Delete Points" + await expect(deleteButton).toContainText('Delete Points'); + }); + + test('should show point count badge on delete button', async ({ page }) => { + await drawSelectionRectangle(page); + await page.waitForTimeout(1000); + + // Check for badge with count + const badge = page.locator('#delete-selection-button .badge'); + await expect(badge).toBeVisible(); + + // Badge should contain a number + const badgeText = await badge.textContent(); + expect(parseInt(badgeText)).toBeGreaterThan(0); + }); + + test('should show cancel button alongside delete button', async ({ page }) => { + await drawSelectionRectangle(page); + await page.waitForTimeout(1000); + + // Check both buttons exist + const cancelButton = page.locator('#cancel-selection-button'); + const deleteButton = page.locator('#delete-selection-button'); + + await expect(cancelButton).toBeVisible(); + await expect(deleteButton).toBeVisible(); + await expect(cancelButton).toContainText('Cancel'); + }); + + test('should cancel selection when cancel button is clicked', async ({ page }) => { + await drawSelectionRectangle(page); + await page.waitForTimeout(1000); + + // Click cancel button + const cancelButton = page.locator('#cancel-selection-button'); + await cancelButton.click(); + await page.waitForTimeout(500); + + // Verify buttons are gone + await expect(cancelButton).not.toBeVisible(); + await expect(page.locator('#delete-selection-button')).not.toBeVisible(); + + // Verify selection is cleared + const isSelectionActive = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.isSelectionActive === false; + }); + + expect(isSelectionActive).toBe(true); + }); + + test('should show confirmation dialog when delete button is clicked', async ({ page }) => { + // Set up dialog handler + let dialogMessage = ''; + page.on('dialog', async dialog => { + dialogMessage = dialog.message(); + await dialog.dismiss(); // Dismiss to prevent actual deletion + }); + + await drawSelectionRectangle(page); + await page.waitForTimeout(1000); + + // Click delete button + const deleteButton = page.locator('#delete-selection-button'); + await deleteButton.click(); + await page.waitForTimeout(500); + + // Verify confirmation dialog appeared with warning + expect(dialogMessage).toContain('WARNING'); + expect(dialogMessage).toContain('permanently delete'); + expect(dialogMessage).toContain('cannot be undone'); + }); + + test('should delete points and show success message when confirmed', async ({ page }) => { + // Set up dialog handler to accept deletion + page.on('dialog', async dialog => { + await dialog.accept(); + }); + + // Get initial point count + const initialPointCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.markers?.length || 0; + }); + + await drawSelectionRectangle(page); + await page.waitForTimeout(1000); + + // Click delete button + const deleteButton = page.locator('#delete-selection-button'); + await deleteButton.click(); + await page.waitForTimeout(2000); // Wait for deletion to complete + + // Check for success flash message with specific text + const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Successfully deleted")'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + const messageText = await flashMessage.textContent(); + expect(messageText).toMatch(/Successfully deleted \d+ point/); + + // Verify point count decreased + const finalPointCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.markers?.length || 0; + }); + + expect(finalPointCount).toBeLessThan(initialPointCount); + }); + + test('should preserve Routes layer disabled state after deletion', async ({ page }) => { + // Ensure Routes layer is disabled + await page.locator('.leaflet-control-layers').hover(); + await page.waitForTimeout(300); + + const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]'); + const isRoutesChecked = await routesCheckbox.isChecked(); + if (isRoutesChecked) { + await routesCheckbox.uncheck(); + await page.waitForTimeout(500); + } + + // Set up dialog handler to accept deletion + page.on('dialog', async dialog => { + await dialog.accept(); + }); + + // Perform deletion using same selection logic as helper + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + + // Use larger selection area to ensure we select points + const startX = bbox.x + bbox.width * 0.2; + const startY = bbox.y + bbox.height * 0.2; + const endX = bbox.x + bbox.width * 0.8; + const endY = bbox.y + bbox.height * 0.8; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(2000); + + // Wait for drawer and button to appear + await page.waitForSelector('#visits-drawer.open', { timeout: 15000 }); + await page.waitForSelector('#delete-selection-button', { timeout: 15000 }); + + const deleteButton = page.locator('#delete-selection-button'); + await deleteButton.click(); + await page.waitForTimeout(2000); + + // Verify Routes layer is still disabled + const isRoutesLayerVisible = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.map?.hasLayer(controller?.polylinesLayer); + }); + + expect(isRoutesLayerVisible).toBe(false); + }); + + test('should preserve Routes layer enabled state after deletion', async ({ page }) => { + // Enable Routes layer + await page.locator('.leaflet-control-layers').hover(); + await page.waitForTimeout(300); + + const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]'); + const isRoutesChecked = await routesCheckbox.isChecked(); + if (!isRoutesChecked) { + await routesCheckbox.check(); + await page.waitForTimeout(1000); + } + + // Set up dialog handler to accept deletion + page.on('dialog', async dialog => { + await dialog.accept(); + }); + + // Perform deletion using same selection logic as helper + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + + // Use larger selection area to ensure we select points + const startX = bbox.x + bbox.width * 0.2; + const startY = bbox.y + bbox.height * 0.2; + const endX = bbox.x + bbox.width * 0.8; + const endY = bbox.y + bbox.height * 0.8; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(2000); + + // Wait for drawer and button to appear + await page.waitForSelector('#visits-drawer.open', { timeout: 15000 }); + await page.waitForSelector('#delete-selection-button', { timeout: 15000 }); + + const deleteButton = page.locator('#delete-selection-button'); + await deleteButton.click(); + await page.waitForTimeout(2000); + + // Verify Routes layer is still enabled + const isRoutesLayerVisible = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.map?.hasLayer(controller?.polylinesLayer); + }); + + expect(isRoutesLayerVisible).toBe(true); + }); + + test('should update heatmap after bulk deletion', async ({ page }) => { + // Enable Heatmap layer + await page.locator('.leaflet-control-layers').hover(); + await page.waitForTimeout(300); + + const heatmapCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Heatmap") input[type="checkbox"]'); + const isHeatmapChecked = await heatmapCheckbox.isChecked(); + if (!isHeatmapChecked) { + await heatmapCheckbox.check(); + await page.waitForTimeout(1000); + } + + // Get initial heatmap data count + const initialHeatmapCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.heatmapLayer?._latlngs?.length || 0; + }); + + // Set up dialog handler to accept deletion + page.on('dialog', async dialog => { + await dialog.accept(); + }); + + // Perform deletion using same selection logic as helper + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + + // Use larger selection area to ensure we select points + const startX = bbox.x + bbox.width * 0.2; + const startY = bbox.y + bbox.height * 0.2; + const endX = bbox.x + bbox.width * 0.8; + const endY = bbox.y + bbox.height * 0.8; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(2000); + + // Wait for drawer and button to appear + await page.waitForSelector('#visits-drawer.open', { timeout: 15000 }); + await page.waitForSelector('#delete-selection-button', { timeout: 15000 }); + + const deleteButton = page.locator('#delete-selection-button'); + await deleteButton.click(); + await page.waitForTimeout(2000); + + // Verify heatmap was updated + const finalHeatmapCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.heatmapLayer?._latlngs?.length || 0; + }); + + expect(finalHeatmapCount).toBeLessThan(initialHeatmapCount); + }); + + test('should clear selection after successful deletion', async ({ page }) => { + // Set up dialog handler to accept deletion + page.on('dialog', async dialog => { + await dialog.accept(); + }); + + // Perform deletion using same selection logic as helper + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + + // Use larger selection area to ensure we select points + const startX = bbox.x + bbox.width * 0.2; + const startY = bbox.y + bbox.height * 0.2; + const endX = bbox.x + bbox.width * 0.8; + const endY = bbox.y + bbox.height * 0.8; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 10 }); + await page.mouse.up(); + await page.waitForTimeout(2000); + + // Wait for drawer and button to appear + await page.waitForSelector('#visits-drawer.open', { timeout: 15000 }); + await page.waitForSelector('#delete-selection-button', { timeout: 15000 }); + + const deleteButton = page.locator('#delete-selection-button'); + await deleteButton.click(); + await page.waitForTimeout(2000); + + // Verify selection is cleared + const isSelectionActive = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.isSelectionActive === false && + controller?.visitsManager?.selectedPoints?.length === 0; + }); + + expect(isSelectionActive).toBe(true); + + // Verify buttons are removed + await expect(page.locator('#cancel-selection-button')).not.toBeVisible(); + await expect(page.locator('#delete-selection-button')).not.toBeVisible(); + }); +}); diff --git a/e2e/map/map-calendar-panel.spec.js b/e2e/map/map-calendar-panel.spec.js new file mode 100644 index 00000000..e0c3af55 --- /dev/null +++ b/e2e/map/map-calendar-panel.spec.js @@ -0,0 +1,308 @@ +import { test, expect } from '@playwright/test'; +import { closeOnboardingModal } from '../helpers/navigation.js'; + +/** + * Calendar Panel Tests + * + * Tests for the calendar panel control that allows users to navigate between + * different years and months. The panel is opened via the "Toggle Panel" button + * in the top-right corner of the map. + */ + +test.describe('Calendar Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/map'); + await closeOnboardingModal(page); + + // Wait for map to be fully loaded + await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 }); + await page.waitForTimeout(2000); // Wait for all controls to be initialized + }); + + /** + * Helper function to find and click the calendar toggle button + */ + async function clickCalendarButton(page) { + // The calendar button is the "Toggle Panel" button with a calendar icon + // It's the third button in the top-right control stack (after Select Area and Add Visit) + const calendarButton = await page.locator('button.toggle-panel-button').first(); + await expect(calendarButton).toBeVisible({ timeout: 5000 }); + await calendarButton.click(); + await page.waitForTimeout(500); // Wait for panel animation + } + + /** + * Helper function to check if panel is visible + */ + async function isPanelVisible(page) { + const panel = page.locator('.leaflet-right-panel'); + const isVisible = await panel.isVisible().catch(() => false); + if (!isVisible) return false; + + const displayStyle = await panel.evaluate(el => el.style.display); + return displayStyle !== 'none'; + } + + test('should open calendar panel on first click', async ({ page }) => { + // Verify panel is not visible initially + const initiallyVisible = await isPanelVisible(page); + expect(initiallyVisible).toBe(false); + + // Click calendar button + await clickCalendarButton(page); + + // Verify panel is now visible + const panelVisible = await isPanelVisible(page); + expect(panelVisible).toBe(true); + + // Verify panel contains expected elements + const yearSelect = page.locator('#year-select'); + await expect(yearSelect).toBeVisible(); + + const monthsGrid = page.locator('#months-grid'); + await expect(monthsGrid).toBeVisible(); + + // Verify "Whole year" link is present + const wholeYearLink = page.locator('#whole-year-link'); + await expect(wholeYearLink).toBeVisible(); + }); + + test('should close calendar panel on second click', async ({ page }) => { + // Open panel + await clickCalendarButton(page); + await page.waitForTimeout(300); + + // Verify panel is visible + let panelVisible = await isPanelVisible(page); + expect(panelVisible).toBe(true); + + // Click button again to close + await clickCalendarButton(page); + await page.waitForTimeout(300); + + // Verify panel is hidden + panelVisible = await isPanelVisible(page); + expect(panelVisible).toBe(false); + }); + + test('should allow year selection', async ({ page }) => { + // Open panel + await clickCalendarButton(page); + + // Wait for year select to be populated (it loads from API) + await page.waitForTimeout(2000); + + const yearSelect = page.locator('#year-select'); + await expect(yearSelect).toBeVisible(); + + // Get available years + const options = await yearSelect.locator('option:not([disabled])').all(); + + // Should have at least one year available + expect(options.length).toBeGreaterThan(0); + + // Select the first available year + const firstYearOption = options[0]; + const yearValue = await firstYearOption.getAttribute('value'); + + await yearSelect.selectOption(yearValue); + + // Verify year was selected + const selectedValue = await yearSelect.inputValue(); + expect(selectedValue).toBe(yearValue); + }); + + test('should navigate to month when clicking month button', async ({ page }) => { + // Open panel + await clickCalendarButton(page); + + // Wait for months to load + await page.waitForTimeout(3000); + + // Select year 2024 (which has October data in demo) + const yearSelect = page.locator('#year-select'); + await yearSelect.selectOption('2024'); + await page.waitForTimeout(500); + + // Find October button (demo data has October 2024) + const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]'); + await expect(octoberButton).toBeVisible({ timeout: 5000 }); + + // Verify October is enabled (not disabled) + const isDisabled = await octoberButton.evaluate(el => el.classList.contains('disabled')); + expect(isDisabled).toBe(false); + + // Verify button is clickable + const pointerEvents = await octoberButton.evaluate(el => el.style.pointerEvents); + expect(pointerEvents).not.toBe('none'); + + // Get the expected href before clicking + const expectedHref = await octoberButton.getAttribute('href'); + expect(expectedHref).toBeTruthy(); + const decodedHref = decodeURIComponent(expectedHref); + + expect(decodedHref).toContain('map?'); + expect(decodedHref).toContain('start_at=2024-10-01T00:00'); + expect(decodedHref).toContain('end_at=2024-10-31T23:59'); + + // Click the month button and wait for navigation + await Promise.all([ + page.waitForURL('**/map**', { timeout: 10000 }), + octoberButton.click() + ]); + + // Wait for page to settle + await page.waitForLoadState('networkidle', { timeout: 10000 }); + + // Verify we navigated to the map page + expect(page.url()).toContain('/map'); + + // Verify map loaded with data + await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 }); + }); + + test('should navigate to whole year when clicking "Whole year" button', async ({ page }) => { + // Open panel + await clickCalendarButton(page); + + // Wait for panel to load + await page.waitForTimeout(2000); + + const wholeYearLink = page.locator('#whole-year-link'); + await expect(wholeYearLink).toBeVisible(); + + // Get the href and decode it + const href = await wholeYearLink.getAttribute('href'); + expect(href).toBeTruthy(); + const decodedHref = decodeURIComponent(href); + + expect(decodedHref).toContain('map?'); + expect(decodedHref).toContain('start_at='); + expect(decodedHref).toContain('end_at='); + + // Href should contain full year dates (01-01 to 12-31) + expect(decodedHref).toContain('-01-01T00:00'); + expect(decodedHref).toContain('-12-31T23:59'); + + // Store the expected year from the href + const yearMatch = decodedHref.match(/(\d{4})-01-01/); + expect(yearMatch).toBeTruthy(); + const expectedYear = yearMatch[1]; + + // Click the link and wait for navigation + await Promise.all([ + page.waitForURL('**/map**', { timeout: 10000 }), + wholeYearLink.click() + ]); + + // Wait for page to settle + await page.waitForLoadState('networkidle', { timeout: 10000 }); + + // Verify we navigated to the map page + expect(page.url()).toContain('/map'); + + // The URL parameters might be processed differently (e.g., stripped by Turbo or redirected) + // Instead of checking URL, verify the panel updates to show the whole year is selected + // by checking the year in the select dropdown + const panelVisible = await isPanelVisible(page); + if (!panelVisible) { + // Panel might have closed on navigation, reopen it + await clickCalendarButton(page); + await page.waitForTimeout(1000); + } + + const yearSelect = page.locator('#year-select'); + const selectedYear = await yearSelect.inputValue(); + expect(selectedYear).toBe(expectedYear); + }); + + test('should update month buttons when year is changed', async ({ page }) => { + // Open panel + await clickCalendarButton(page); + + // Wait for data to load + await page.waitForTimeout(2000); + + const yearSelect = page.locator('#year-select'); + + // Get available years + const options = await yearSelect.locator('option:not([disabled])').all(); + + if (options.length < 2) { + console.log('Test skipped: Less than 2 years available'); + test.skip(); + return; + } + + // Select first year and capture month states + const firstYearOption = options[0]; + const firstYear = await firstYearOption.getAttribute('value'); + await yearSelect.selectOption(firstYear); + await page.waitForTimeout(500); + + // Get enabled months for first year + const firstYearMonths = await page.locator('#months-grid a:not(.disabled)').count(); + + // Select second year + const secondYearOption = options[1]; + const secondYear = await secondYearOption.getAttribute('value'); + await yearSelect.selectOption(secondYear); + await page.waitForTimeout(500); + + // Get enabled months for second year + const secondYearMonths = await page.locator('#months-grid a:not(.disabled)').count(); + + // Months should be different (unless both years have same tracked months) + // At minimum, verify that month buttons are updated (content changed from loading dots) + const monthButtons = await page.locator('#months-grid a').all(); + + for (const button of monthButtons) { + const buttonText = await button.textContent(); + // Should not contain loading dots anymore + expect(buttonText).not.toContain('loading'); + } + }); + + test('should highlight active month based on current URL parameters', async ({ page }) => { + // Navigate to a specific month first + await page.goto('/map?start_at=2024-10-01T00:00&end_at=2024-10-31T23:59'); + await closeOnboardingModal(page); + await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 }); + await page.waitForTimeout(2000); + + // Open calendar panel + await clickCalendarButton(page); + await page.waitForTimeout(2000); + + // Find October button (month index 9, displayed as "Oct") + const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]'); + await expect(octoberButton).toBeVisible(); + + // Verify October is marked as active + const hasActiveClass = await octoberButton.evaluate(el => + el.classList.contains('btn-active') + ); + expect(hasActiveClass).toBe(true); + }); + + test('should show visited cities section in panel', async ({ page }) => { + // Open panel + await clickCalendarButton(page); + await page.waitForTimeout(2000); + + // Verify visited cities section is present + const visitedCitiesContainer = page.locator('#visited-cities-container'); + await expect(visitedCitiesContainer).toBeVisible(); + + const visitedCitiesTitle = visitedCitiesContainer.locator('h3'); + await expect(visitedCitiesTitle).toHaveText('Visited cities'); + + const visitedCitiesList = page.locator('#visited-cities-list'); + await expect(visitedCitiesList).toBeVisible(); + + // List should eventually load (either with cities or "No places visited") + await page.waitForTimeout(2000); + const listContent = await visitedCitiesList.textContent(); + expect(listContent.length).toBeGreaterThan(0); + }); +}); diff --git a/e2e/map/map-controls.spec.js b/e2e/map/map-controls.spec.js new file mode 100644 index 00000000..bbed6e39 --- /dev/null +++ b/e2e/map/map-controls.spec.js @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; +import { navigateToMap, closeOnboardingModal, navigateToDate } from '../helpers/navigation.js'; +import { waitForMap, getMapZoom } from '../helpers/map.js'; + +test.describe('Map Page', () => { + test.beforeEach(async ({ page }) => { + await navigateToMap(page); + }); + + test('should load map container and display map with controls', async ({ page }) => { + await expect(page.locator('#map')).toBeVisible(); + await waitForMap(page); + + // Verify zoom controls are present + await expect(page.locator('.leaflet-control-zoom')).toBeVisible(); + + // Verify custom map controls are present (from map_controls.js) + await expect(page.locator('.add-visit-button')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.toggle-panel-button')).toBeVisible(); + await expect(page.locator('.drawer-button')).toBeVisible(); + await expect(page.locator('#selection-tool-button')).toBeVisible(); + }); + + test('should zoom in when clicking zoom in button', async ({ page }) => { + await waitForMap(page); + + const initialZoom = await getMapZoom(page); + await page.locator('.leaflet-control-zoom-in').click(); + await page.waitForTimeout(500); + const newZoom = await getMapZoom(page); + + expect(newZoom).toBeGreaterThan(initialZoom); + }); + + test('should zoom out when clicking zoom out button', async ({ page }) => { + await waitForMap(page); + + const initialZoom = await getMapZoom(page); + await page.locator('.leaflet-control-zoom-out').click(); + await page.waitForTimeout(500); + const newZoom = await getMapZoom(page); + + expect(newZoom).toBeLessThan(initialZoom); + }); + + test('should switch between map tile layers', async ({ page }) => { + await waitForMap(page); + + await page.locator('.leaflet-control-layers').hover(); + await page.waitForTimeout(300); + + const getSelectedLayer = () => page.evaluate(() => { + const radio = document.querySelector('.leaflet-control-layers-base input[type="radio"]:checked'); + return radio ? radio.nextSibling.textContent.trim() : null; + }); + + const initialLayer = await getSelectedLayer(); + await page.locator('.leaflet-control-layers-base input[type="radio"]:not(:checked)').first().click(); + await page.waitForTimeout(500); + const newLayer = await getSelectedLayer(); + + expect(newLayer).not.toBe(initialLayer); + }); + + test('should navigate to specific date and display points layer', async ({ page }) => { + // Wait for map to be ready + await page.waitForFunction(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container && container._leaflet_id !== undefined; + }, { timeout: 10000 }); + + // Navigate to date 13.10.2024 + // First, need to expand the date controls on mobile (if collapsed) + const toggleButton = page.locator('button[data-action*="map-controls#toggle"]'); + const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible(); + + if (!isPanelVisible) { + await toggleButton.click(); + await page.waitForTimeout(300); + } + + // Clear and fill in the start date/time input (midnight) + const startInput = page.locator('input[type="datetime-local"][name="start_at"]'); + await startInput.clear(); + await startInput.fill('2024-10-13T00:00'); + + // Clear and fill in the end date/time input (end of day) + const endInput = page.locator('input[type="datetime-local"][name="end_at"]'); + await endInput.clear(); + await endInput.fill('2024-10-13T23:59'); + + // Click the Search button to submit + await page.click('input[type="submit"][value="Search"]'); + + // Wait for page navigation and map reload + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); // Wait for map to reinitialize + + // Close onboarding modal if it appears after navigation + await closeOnboardingModal(page); + + // Open layer control to enable points + await page.locator('.leaflet-control-layers').hover(); + await page.waitForTimeout(300); + + // Enable points layer if not already enabled + const pointsCheckbox = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]').first(); + const isChecked = await pointsCheckbox.isChecked(); + + if (!isChecked) { + await pointsCheckbox.check(); + await page.waitForTimeout(1000); // Wait for points to render + } + + // Verify points are visible on the map + const layerInfo = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + + if (!controller) { + return { error: 'Controller not found' }; + } + + const result = { + hasMarkersLayer: !!controller.markersLayer, + markersCount: 0, + hasPolylinesLayer: !!controller.polylinesLayer, + polylinesCount: 0, + hasTracksLayer: !!controller.tracksLayer, + tracksCount: 0, + }; + + // Check markers layer + if (controller.markersLayer && controller.markersLayer._layers) { + result.markersCount = Object.keys(controller.markersLayer._layers).length; + } + + // Check polylines layer + if (controller.polylinesLayer && controller.polylinesLayer._layers) { + result.polylinesCount = Object.keys(controller.polylinesLayer._layers).length; + } + + // Check tracks layer + if (controller.tracksLayer && controller.tracksLayer._layers) { + result.tracksCount = Object.keys(controller.tracksLayer._layers).length; + } + + return result; + }); + + // Verify that at least one layer has data + const hasData = layerInfo.markersCount > 0 || + layerInfo.polylinesCount > 0 || + layerInfo.tracksCount > 0; + + expect(hasData).toBe(true); + }); +}); diff --git a/e2e/map/map-layers.spec.js b/e2e/map/map-layers.spec.js new file mode 100644 index 00000000..f5330f9c --- /dev/null +++ b/e2e/map/map-layers.spec.js @@ -0,0 +1,184 @@ +import { test, expect } from '@playwright/test'; +import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js'; +import { waitForMap, enableLayer } from '../helpers/map.js'; + +test.describe('Map Layers', () => { + test.beforeEach(async ({ page }) => { + await navigateToMap(page); + }); + + test('should enable Routes layer and display routes', async ({ page }) => { + // Wait for map to be ready + await page.waitForFunction(() => { + const container = document.querySelector('#map [data-maps-target="container"]'); + return container && container._leaflet_id !== undefined; + }, { timeout: 10000 }); + + // Navigate to date with data + const toggleButton = page.locator('button[data-action*="map-controls#toggle"]'); + const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible(); + + if (!isPanelVisible) { + await toggleButton.click(); + await page.waitForTimeout(300); + } + + const startInput = page.locator('input[type="datetime-local"][name="start_at"]'); + await startInput.clear(); + await startInput.fill('2024-10-13T00:00'); + + const endInput = page.locator('input[type="datetime-local"][name="end_at"]'); + await endInput.clear(); + await endInput.fill('2024-10-13T23:59'); + + await page.click('input[type="submit"][value="Search"]'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + // Close onboarding modal if present + await closeOnboardingModal(page); + + // Open layer control and enable Routes + await page.locator('.leaflet-control-layers').hover(); + await page.waitForTimeout(300); + + const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]'); + const isChecked = await routesCheckbox.isChecked(); + + if (!isChecked) { + await routesCheckbox.check(); + await page.waitForTimeout(1000); + } + + // Verify routes are visible + const hasRoutes = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.polylinesLayer && controller.polylinesLayer._layers) { + return Object.keys(controller.polylinesLayer._layers).length > 0; + } + return false; + }); + + expect(hasRoutes).toBe(true); + }); + + test('should enable Heatmap layer and display heatmap', async ({ page }) => { + await waitForMap(page); + await enableLayer(page, 'Heatmap'); + + const hasHeatmap = await page.locator('.leaflet-heatmap-layer').isVisible(); + expect(hasHeatmap).toBe(true); + }); + + test('should enable Fog of War layer and display fog', async ({ page }) => { + await waitForMap(page); + await enableLayer(page, 'Fog of War'); + + const hasFog = await page.evaluate(() => { + const fogCanvas = document.getElementById('fog'); + return fogCanvas && fogCanvas instanceof HTMLCanvasElement; + }); + + expect(hasFog).toBe(true); + }); + + test('should enable Areas layer and display areas', async ({ page }) => { + await waitForMap(page); + + const hasAreasLayer = await page.evaluate(() => { + const mapElement = document.querySelector('#map'); + const app = window.Stimulus; + const controller = app?.getControllerForElementAndIdentifier(mapElement, 'maps'); + return controller?.areasLayer !== null && controller?.areasLayer !== undefined; + }); + + expect(hasAreasLayer).toBe(true); + }); + + test('should enable Suggested Visits layer', async ({ page }) => { + await waitForMap(page); + await enableLayer(page, 'Suggested Visits'); + + const hasSuggestedVisits = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.visitCircles !== null && + controller?.visitsManager?.visitCircles !== undefined; + }); + + expect(hasSuggestedVisits).toBe(true); + }); + + test('should enable Confirmed Visits layer', async ({ page }) => { + await waitForMap(page); + await enableLayer(page, 'Confirmed Visits'); + + const hasConfirmedVisits = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.confirmedVisitCircles !== null && + controller?.visitsManager?.confirmedVisitCircles !== undefined; + }); + + expect(hasConfirmedVisits).toBe(true); + }); + + test('should enable Scratch Map layer and display visited countries', async ({ page }) => { + await waitForMap(page); + await enableLayer(page, 'Scratch Map'); + + // Wait a bit for the layer to load country borders + await page.waitForTimeout(2000); + + // Verify scratch layer exists and has been initialized + const hasScratchLayer = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + + // Check if scratchLayerManager exists + if (!controller?.scratchLayerManager) return false; + + // Check if scratch layer was created + const scratchLayer = controller.scratchLayerManager.getLayer(); + return scratchLayer !== null && scratchLayer !== undefined; + }); + + expect(hasScratchLayer).toBe(true); + }); + + test('should remember enabled layers across page reloads', async ({ page }) => { + await waitForMap(page); + + // Enable multiple layers + await enableLayer(page, 'Points'); + await enableLayer(page, 'Routes'); + await enableLayer(page, 'Heatmap'); + await page.waitForTimeout(500); + + // Get current layer states + const getLayerStates = () => page.evaluate(() => { + const layers = {}; + document.querySelectorAll('.leaflet-control-layers-overlays input[type="checkbox"]').forEach(checkbox => { + const label = checkbox.parentElement.textContent.trim(); + layers[label] = checkbox.checked; + }); + return layers; + }); + + const layersBeforeReload = await getLayerStates(); + + // Reload the page + await page.reload(); + await closeOnboardingModal(page); + await waitForMap(page); + await page.waitForTimeout(1000); // Wait for layers to restore + + // Get layer states after reload + const layersAfterReload = await getLayerStates(); + + // Verify Points, Routes, and Heatmap are still enabled + expect(layersAfterReload['Points']).toBe(true); + expect(layersAfterReload['Routes']).toBe(true); + expect(layersAfterReload['Heatmap']).toBe(true); + + // Verify layer states match before and after + expect(layersAfterReload).toEqual(layersBeforeReload); + }); +}); diff --git a/e2e/map/map-points.spec.js b/e2e/map/map-points.spec.js new file mode 100644 index 00000000..075f5624 --- /dev/null +++ b/e2e/map/map-points.spec.js @@ -0,0 +1,141 @@ +import { test, expect } from '@playwright/test'; +import { navigateToMap } from '../helpers/navigation.js'; +import { waitForMap, enableLayer } from '../helpers/map.js'; + +test.describe('Point Interactions', () => { + test.beforeEach(async ({ page }) => { + await navigateToMap(page); + await waitForMap(page); + await enableLayer(page, 'Points'); + await page.waitForTimeout(1500); + + // Pan map to ensure a marker is in viewport + await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.markers && controller.markers.length > 0) { + const firstMarker = controller.markers[0]; + controller.map.setView([firstMarker[0], firstMarker[1]], 14); + } + }); + await page.waitForTimeout(1000); + }); + + test('should have draggable markers on the map', async ({ page }) => { + // Verify markers have draggable class + const marker = page.locator('.leaflet-marker-icon').first(); + await expect(marker).toBeVisible(); + + // Check if marker has draggable class + const isDraggable = await marker.evaluate((el) => { + return el.classList.contains('leaflet-marker-draggable'); + }); + + expect(isDraggable).toBe(true); + + // Verify marker position can be retrieved (required for drag operations) + const box = await marker.boundingBox(); + expect(box).not.toBeNull(); + expect(box.x).toBeGreaterThan(0); + expect(box.y).toBeGreaterThan(0); + }); + + test('should open popup when clicking a point', async ({ page }) => { + // Click on a marker with force to ensure interaction + const marker = page.locator('.leaflet-marker-icon').first(); + await marker.click({ force: true }); + await page.waitForTimeout(500); + + // Verify popup is visible + const popup = page.locator('.leaflet-popup'); + await expect(popup).toBeVisible(); + }); + + test('should display correct popup content with point data', async ({ page }) => { + // Click on a marker + const marker = page.locator('.leaflet-marker-icon').first(); + await marker.click({ force: true }); + await page.waitForTimeout(500); + + // Get popup content + const popupContent = page.locator('.leaflet-popup-content'); + await expect(popupContent).toBeVisible(); + + const content = await popupContent.textContent(); + + // Verify all required fields are present + expect(content).toContain('Timestamp:'); + expect(content).toContain('Latitude:'); + expect(content).toContain('Longitude:'); + expect(content).toContain('Altitude:'); + expect(content).toContain('Speed:'); + expect(content).toContain('Battery:'); + expect(content).toContain('Id:'); + }); + + test('should delete a point and redraw route', async ({ page }) => { + // Enable Routes layer to verify route redraw + await enableLayer(page, 'Routes'); + await page.waitForTimeout(1000); + + // Count initial markers and get point ID + const initialData = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0; + const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0; + return { markerCount, polylineCount }; + }); + + // Click on a marker to open popup + const marker = page.locator('.leaflet-marker-icon').first(); + await marker.click({ force: true }); + await page.waitForTimeout(500); + + // Verify popup opened + await expect(page.locator('.leaflet-popup')).toBeVisible(); + + // Get the point ID from popup before deleting + const pointId = await page.locator('.leaflet-popup-content').evaluate((content) => { + const match = content.textContent.match(/Id:\s*(\d+)/); + return match ? match[1] : null; + }); + + expect(pointId).not.toBeNull(); + + // Find delete button (might be a link or button with "Delete" text) + const deleteButton = page.locator('.leaflet-popup-content a:has-text("Delete"), .leaflet-popup-content button:has-text("Delete")').first(); + + const hasDeleteButton = await deleteButton.count() > 0; + + if (hasDeleteButton) { + // Handle confirmation dialog + page.once('dialog', dialog => { + expect(dialog.message()).toContain('delete'); + dialog.accept(); + }); + + await deleteButton.click(); + await page.waitForTimeout(2000); // Wait for deletion to complete + + // Verify marker count decreased + const finalData = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0; + const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0; + return { markerCount, polylineCount }; + }); + + // Verify at least one marker was removed + expect(finalData.markerCount).toBeLessThan(initialData.markerCount); + + // Verify routes still exist (they should be redrawn) + expect(finalData.polylineCount).toBeGreaterThanOrEqual(0); + + // Verify success flash message appears + const flashMessage = page.locator('#flash-messages [role="alert"]').filter({ hasText: /deleted successfully/i }); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + } else { + // If no delete button, just verify the test setup worked + console.log('No delete button found in popup - this might be expected based on permissions'); + } + }); +}); diff --git a/e2e/map/map-selection-tool.spec.js b/e2e/map/map-selection-tool.spec.js new file mode 100644 index 00000000..0ce06eea --- /dev/null +++ b/e2e/map/map-selection-tool.spec.js @@ -0,0 +1,166 @@ +import { test, expect } from '@playwright/test'; +import { navigateToMap } from '../helpers/navigation.js'; +import { waitForMap } from '../helpers/map.js'; + +test.describe('Selection Tool', () => { + test.beforeEach(async ({ page }) => { + await navigateToMap(page); + await waitForMap(page); + }); + + test('should enable selection mode when clicked', async ({ page }) => { + // Click selection tool button + const selectionButton = page.locator('#selection-tool-button'); + await expect(selectionButton).toBeVisible(); + await selectionButton.click(); + await page.waitForTimeout(500); + + // Verify selection mode is enabled (flash message appears) + const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Selection mode enabled")'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // Verify selection mode is active in controller + const isSelectionActive = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.isSelectionActive === true; + }); + + expect(isSelectionActive).toBe(true); + + // Verify button has active class + const hasActiveClass = await selectionButton.evaluate((el) => { + return el.classList.contains('active'); + }); + + expect(hasActiveClass).toBe(true); + + // Verify map dragging is disabled (required for selection to work) + const isDraggingDisabled = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return !controller?.map?.dragging?.enabled(); + }); + + expect(isDraggingDisabled).toBe(true); + }); + + test('should disable selection mode when clicked second time', async ({ page }) => { + const selectionButton = page.locator('#selection-tool-button'); + + // First click - enable selection mode + await selectionButton.click(); + await page.waitForTimeout(500); + + // Verify selection mode is enabled + const isEnabledAfterFirstClick = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.isSelectionActive === true; + }); + + expect(isEnabledAfterFirstClick).toBe(true); + + // Second click - disable selection mode + await selectionButton.click(); + await page.waitForTimeout(500); + + // Verify selection mode is disabled + const isDisabledAfterSecondClick = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.isSelectionActive === false; + }); + + expect(isDisabledAfterSecondClick).toBe(true); + + // Verify no selection rectangle exists + const hasSelectionRect = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.visitsManager?.selectionRect !== null; + }); + + expect(hasSelectionRect).toBe(false); + + // Verify button no longer has active class + const hasActiveClass = await selectionButton.evaluate((el) => { + return el.classList.contains('active'); + }); + + expect(hasActiveClass).toBe(false); + + // Verify map dragging is re-enabled + const isDraggingEnabled = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return controller?.map?.dragging?.enabled(); + }); + + expect(isDraggingEnabled).toBe(true); + }); + + test('should show info message about dragging to select area', async ({ page }) => { + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + // Verify informational flash message about dragging + const flashMessage = page.locator('#flash-messages [role="alert"]'); + const messageText = await flashMessage.textContent(); + + expect(messageText).toContain('Click and drag'); + }); + + test('should open side panel when selection is complete', async ({ page }) => { + // Navigate to a date with known data (October 13, 2024 - same as bulk delete tests) + const startInput = page.locator('input[type="datetime-local"][name="start_at"]'); + await startInput.clear(); + await startInput.fill('2024-10-13T00:00'); + + const endInput = page.locator('input[type="datetime-local"][name="end_at"]'); + await endInput.clear(); + await endInput.fill('2024-10-13T23:59'); + + await page.click('input[type="submit"][value="Search"]'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + + // Verify drawer is initially closed + const drawerInitiallyClosed = await page.evaluate(() => { + const drawer = document.getElementById('visits-drawer'); + return !drawer?.classList.contains('open'); + }); + + expect(drawerInitiallyClosed).toBe(true); + + // Enable selection mode + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + // Draw a selection rectangle on the map + const mapContainer = page.locator('#map [data-maps-target="container"]'); + const bbox = await mapContainer.boundingBox(); + + // Draw rectangle covering most of the map to ensure we select points + const startX = bbox.x + bbox.width * 0.2; + const startY = bbox.y + bbox.height * 0.2; + const endX = bbox.x + bbox.width * 0.8; + const endY = bbox.y + bbox.height * 0.8; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 10 }); + await page.mouse.up(); + + // Wait for drawer to open + await page.waitForTimeout(2000); + + // Verify drawer is now open + const drawerOpen = await page.evaluate(() => { + const drawer = document.getElementById('visits-drawer'); + return drawer?.classList.contains('open'); + }); + + expect(drawerOpen).toBe(true); + + // Verify drawer shows either selection data or cancel button (indicates selection is active) + const hasCancelButton = await page.locator('#cancel-selection-button').isVisible(); + expect(hasCancelButton).toBe(true); + }); +}); diff --git a/e2e/map/map-side-panel.spec.js b/e2e/map/map-side-panel.spec.js new file mode 100644 index 00000000..e09284ed --- /dev/null +++ b/e2e/map/map-side-panel.spec.js @@ -0,0 +1,644 @@ +import { test, expect } from '@playwright/test'; +import { closeOnboardingModal, navigateToDate } from '../helpers/navigation.js'; +import { drawSelectionRectangle } from '../helpers/selection.js'; + +/** + * Side Panel (Visits Drawer) Tests + * + * Tests for the side panel that displays visits when selection tool is used. + * The panel can be toggled via the drawer button and shows suggested/confirmed visits + * with options to confirm, decline, or merge them. + */ + +test.describe('Side Panel', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/map'); + await closeOnboardingModal(page); + + // Wait for map to be fully loaded + await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 }); + await page.waitForTimeout(2000); + + // Navigate to October 2024 (has demo data) + await navigateToDate(page, '2024-10-01T00:00', '2024-10-31T23:59'); + await page.waitForTimeout(2000); + }); + + /** + * Helper function to click the drawer button + */ + async function clickDrawerButton(page) { + const drawerButton = page.locator('.drawer-button'); + await expect(drawerButton).toBeVisible({ timeout: 5000 }); + await drawerButton.click(); + await page.waitForTimeout(500); // Wait for drawer animation + } + + /** + * Helper function to check if drawer is open + */ + async function isDrawerOpen(page) { + const drawer = page.locator('#visits-drawer'); + const exists = await drawer.count() > 0; + if (!exists) return false; + + const hasOpenClass = await drawer.evaluate(el => el.classList.contains('open')); + return hasOpenClass; + } + + /** + * Helper function to perform selection and wait for visits to load + * This is a simplified version that doesn't use the shared helper + * because we need custom waiting logic for the drawer + */ + async function selectAreaWithVisits(page) { + // First, enable Suggested Visits layer to ensure visits are loaded + const layersButton = page.locator('.leaflet-control-layers-toggle'); + await layersButton.click(); + await page.waitForTimeout(500); + + // Enable "Suggested Visits" layer + const suggestedVisitsCheckbox = page.locator('input[type="checkbox"]').filter({ + has: page.locator(':scope ~ span', { hasText: 'Suggested Visits' }) + }); + + const isChecked = await suggestedVisitsCheckbox.isChecked(); + if (!isChecked) { + await suggestedVisitsCheckbox.check(); + await page.waitForTimeout(1000); + } + + // Close layers control + await layersButton.click(); + await page.waitForTimeout(500); + + // Enable selection mode + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + await page.waitForTimeout(500); + + // Get map bounds for drawing selection + const map = page.locator('.leaflet-container'); + const mapBox = await map.boundingBox(); + + // Calculate coordinates for drawing a large selection area + // Make it much wider to catch visits - use most of the map area + const startX = mapBox.x + 100; + const startY = mapBox.y + 100; + const endX = mapBox.x + mapBox.width - 400; // Leave room for drawer on right + const endY = mapBox.y + mapBox.height - 100; + + // Draw selection rectangle + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY, { steps: 10 }); + await page.mouse.up(); + + // Wait for drawer to be created and opened + await page.waitForSelector('#visits-drawer.open', { timeout: 10000 }); + await page.waitForTimeout(3000); // Wait longer for visits API response + } + + test('should open and close drawer panel via button click', async ({ page }) => { + // Verify drawer is initially closed + const initiallyOpen = await isDrawerOpen(page); + expect(initiallyOpen).toBe(false); + + // Click to open + await clickDrawerButton(page); + + // Verify drawer is now open + let drawerOpen = await isDrawerOpen(page); + expect(drawerOpen).toBe(true); + + // Verify drawer content is visible + const drawerContent = page.locator('#visits-drawer .drawer'); + await expect(drawerContent).toBeVisible(); + + // Click to close + await clickDrawerButton(page); + + // Verify drawer is now closed + drawerOpen = await isDrawerOpen(page); + expect(drawerOpen).toBe(false); + }); + + test('should show visits in panel after selection', async ({ page }) => { + await selectAreaWithVisits(page); + + // Verify drawer is open + const drawerOpen = await isDrawerOpen(page); + expect(drawerOpen).toBe(true); + + // Verify visits list container exists + const visitsList = page.locator('#visits-list'); + await expect(visitsList).toBeVisible(); + + // Wait for API response - check if we have visit items or "no visits" message + await page.waitForTimeout(2000); + + // Check what content is actually shown + const visitItems = page.locator('.visit-item'); + const visitCount = await visitItems.count(); + + const noVisitsMessage = page.locator('#visits-list p.text-gray-500'); + + // Either we have visits OR we have a "no visits" message (not "Loading...") + if (visitCount > 0) { + // We have visits - verify the title shows count + const drawerTitle = page.locator('#visits-drawer .drawer h2'); + const titleText = await drawerTitle.textContent(); + expect(titleText).toMatch(/\d+ visits? found/); + } else { + // No visits found - verify we show the appropriate message + // Should NOT still be showing "Loading visits..." + const messageText = await noVisitsMessage.textContent(); + expect(messageText).not.toContain('Loading visits'); + expect(messageText).toContain('No visits'); + } + }); + + test('should display visit details in panel', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + // Check if we have any visits + const visitCount = await page.locator('.visit-item').count(); + + if (visitCount === 0) { + console.log('Test skipped: No visits available in test data'); + test.skip(); + return; + } + + // Get first visit item + const firstVisit = page.locator('.visit-item').first(); + await expect(firstVisit).toBeVisible(); + + // Verify visit has required information + const visitName = firstVisit.locator('.font-semibold'); + await expect(visitName).toBeVisible(); + const nameText = await visitName.textContent(); + expect(nameText.length).toBeGreaterThan(0); + + // Verify time information is present + const timeInfo = firstVisit.locator('.text-sm.text-gray-600'); + await expect(timeInfo).toBeVisible(); + + // Check if this is a suggested visit (has confirm/decline buttons) + const hasSuggestedButtons = (await firstVisit.locator('.confirm-visit').count()) > 0; + + if (hasSuggestedButtons) { + // For suggested visits, verify action buttons are present + const confirmButton = firstVisit.locator('.confirm-visit'); + const declineButton = firstVisit.locator('.decline-visit'); + + await expect(confirmButton).toBeVisible(); + await expect(declineButton).toBeVisible(); + expect(await confirmButton.textContent()).toBe('Confirm'); + expect(await declineButton.textContent()).toBe('Decline'); + } + }); + + test('should confirm individual suggested visit from panel', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + // Find a suggested visit (one with confirm/decline buttons) + const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).first(); + + // Check if any suggested visits exist + const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).count(); + + if (suggestedCount === 0) { + console.log('Test skipped: No suggested visits available'); + test.skip(); + return; + } + + await expect(suggestedVisit).toBeVisible(); + + // Verify it has the suggested visit styling (dashed border) + const hasDashedBorder = await suggestedVisit.evaluate(el => + el.classList.contains('border-dashed') + ); + expect(hasDashedBorder).toBe(true); + + // Get initial count of visits + const initialVisitCount = await page.locator('.visit-item').count(); + + // Click confirm button + const confirmButton = suggestedVisit.locator('.confirm-visit'); + await confirmButton.click(); + + // Wait for API call and UI update + await page.waitForTimeout(2000); + + // Verify flash message appears + const flashMessage = page.locator('.flash-message'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // The visit should still be in the list but without confirm/decline buttons + // Or the count might decrease if it was removed from suggested visits + const finalVisitCount = await page.locator('.visit-item').count(); + expect(finalVisitCount).toBeLessThanOrEqual(initialVisitCount); + }); + + test('should decline individual suggested visit from panel', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + // Find a suggested visit + const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).first(); + + const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).count(); + + if (suggestedCount === 0) { + console.log('Test skipped: No suggested visits available'); + test.skip(); + return; + } + + await expect(suggestedVisit).toBeVisible(); + + // Get initial count + const initialVisitCount = await page.locator('.visit-item').count(); + + // Click decline button + const declineButton = suggestedVisit.locator('.decline-visit'); + await declineButton.click(); + + // Wait for API call and UI update + await page.waitForTimeout(2000); + + // Verify flash message + const flashMessage = page.locator('.flash-message'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // Visit should be removed from the list + const finalVisitCount = await page.locator('.visit-item').count(); + expect(finalVisitCount).toBeLessThan(initialVisitCount); + }); + + test('should show checkboxes on hover for mass selection', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + // Check if we have any visits + const visitCount = await page.locator('.visit-item').count(); + + if (visitCount === 0) { + console.log('Test skipped: No visits available in test data'); + test.skip(); + return; + } + + const firstVisit = page.locator('.visit-item').first(); + await expect(firstVisit).toBeVisible(); + + // Initially, checkbox should be hidden + const checkboxContainer = firstVisit.locator('.visit-checkbox-container'); + let opacity = await checkboxContainer.evaluate(el => el.style.opacity); + expect(opacity === '0' || opacity === '').toBe(true); + + // Hover over the visit item + await firstVisit.hover(); + await page.waitForTimeout(300); + + // Checkbox should now be visible + opacity = await checkboxContainer.evaluate(el => el.style.opacity); + expect(opacity).toBe('1'); + + // Checkbox should be clickable + const pointerEvents = await checkboxContainer.evaluate(el => el.style.pointerEvents); + expect(pointerEvents).toBe('auto'); + }); + + test('should select multiple visits and show bulk action buttons', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + // Verify we have at least 2 visits + const visitCount = await page.locator('.visit-item').count(); + if (visitCount < 2) { + console.log('Test skipped: Need at least 2 visits'); + test.skip(); + return; + } + + // Select first visit by hovering and clicking checkbox + const firstVisit = page.locator('.visit-item').first(); + await firstVisit.hover(); + await page.waitForTimeout(300); + + const firstCheckbox = firstVisit.locator('.visit-checkbox'); + await firstCheckbox.click(); + await page.waitForTimeout(500); + + // Select second visit + const secondVisit = page.locator('.visit-item').nth(1); + await secondVisit.hover(); + await page.waitForTimeout(300); + + const secondCheckbox = secondVisit.locator('.visit-checkbox'); + await secondCheckbox.click(); + await page.waitForTimeout(500); + + // Verify bulk action buttons appear + const bulkActionsContainer = page.locator('.visit-bulk-actions'); + await expect(bulkActionsContainer).toBeVisible(); + + // Verify all three action buttons are present + const mergeButton = bulkActionsContainer.locator('button').filter({ hasText: 'Merge' }); + const confirmButton = bulkActionsContainer.locator('button').filter({ hasText: 'Confirm' }); + const declineButton = bulkActionsContainer.locator('button').filter({ hasText: 'Decline' }); + + await expect(mergeButton).toBeVisible(); + await expect(confirmButton).toBeVisible(); + await expect(declineButton).toBeVisible(); + + // Verify selection count text + const selectionText = bulkActionsContainer.locator('.text-sm.text-center'); + const selectionTextContent = await selectionText.textContent(); + expect(selectionTextContent).toContain('2 visits selected'); + + // Verify cancel button exists + const cancelButton = bulkActionsContainer.locator('button').filter({ hasText: 'Cancel Selection' }); + await expect(cancelButton).toBeVisible(); + }); + + test('should cancel mass selection', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + const visitCount = await page.locator('.visit-item').count(); + if (visitCount < 2) { + console.log('Test skipped: Need at least 2 visits'); + test.skip(); + return; + } + + // Select two visits + const firstVisit = page.locator('.visit-item').first(); + await firstVisit.hover(); + await page.waitForTimeout(300); + await firstVisit.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + const secondVisit = page.locator('.visit-item').nth(1); + await secondVisit.hover(); + await page.waitForTimeout(300); + await secondVisit.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + // Verify bulk actions are visible + const bulkActions = page.locator('.visit-bulk-actions'); + await expect(bulkActions).toBeVisible(); + + // Click cancel button + const cancelButton = bulkActions.locator('button').filter({ hasText: 'Cancel Selection' }); + await cancelButton.click(); + await page.waitForTimeout(500); + + // Verify bulk actions are removed + await expect(bulkActions).not.toBeVisible(); + + // Verify checkboxes are unchecked + const checkedCheckboxes = await page.locator('.visit-checkbox:checked').count(); + expect(checkedCheckboxes).toBe(0); + }); + + test('should mass confirm multiple visits', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + // Find suggested visits (those with confirm buttons) + const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }); + const suggestedCount = await suggestedVisits.count(); + + if (suggestedCount < 2) { + console.log('Test skipped: Need at least 2 suggested visits'); + test.skip(); + return; + } + + // Get initial count + const initialVisitCount = await page.locator('.visit-item').count(); + + // Select first two suggested visits + const firstSuggested = suggestedVisits.first(); + await firstSuggested.hover(); + await page.waitForTimeout(300); + await firstSuggested.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + const secondSuggested = suggestedVisits.nth(1); + await secondSuggested.hover(); + await page.waitForTimeout(300); + await secondSuggested.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + // Click mass confirm button + const bulkActions = page.locator('.visit-bulk-actions'); + const confirmButton = bulkActions.locator('button').filter({ hasText: 'Confirm' }); + await confirmButton.click(); + + // Wait for API call + await page.waitForTimeout(2000); + + // Verify flash message + const flashMessage = page.locator('.flash-message'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // The visits might be removed or updated in the list + // At minimum, bulk actions should be removed + const bulkActionsVisible = await bulkActions.isVisible().catch(() => false); + expect(bulkActionsVisible).toBe(false); + }); + + test('should mass decline multiple visits', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }); + const suggestedCount = await suggestedVisits.count(); + + if (suggestedCount < 2) { + console.log('Test skipped: Need at least 2 suggested visits'); + test.skip(); + return; + } + + // Get initial count + const initialVisitCount = await page.locator('.visit-item').count(); + + // Select two visits + const firstSuggested = suggestedVisits.first(); + await firstSuggested.hover(); + await page.waitForTimeout(300); + await firstSuggested.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + const secondSuggested = suggestedVisits.nth(1); + await secondSuggested.hover(); + await page.waitForTimeout(300); + await secondSuggested.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + // Click mass decline button + const bulkActions = page.locator('.visit-bulk-actions'); + const declineButton = bulkActions.locator('button').filter({ hasText: 'Decline' }); + await declineButton.click(); + + // Wait for API call + await page.waitForTimeout(2000); + + // Verify flash message + const flashMessage = page.locator('.flash-message'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // Visits should be removed from the list + const finalVisitCount = await page.locator('.visit-item').count(); + expect(finalVisitCount).toBeLessThan(initialVisitCount); + }); + + test('should mass merge multiple visits', async ({ page }) => { + await selectAreaWithVisits(page); + + // Open the visits collapsible section + const visitsSection = page.locator('#visits-section-collapse'); + await expect(visitsSection).toBeVisible(); + + const visitsSummary = visitsSection.locator('summary'); + await visitsSummary.click(); + await page.waitForTimeout(500); + + const visitCount = await page.locator('.visit-item').count(); + if (visitCount < 2) { + console.log('Test skipped: Need at least 2 visits'); + test.skip(); + return; + } + + // Select two visits + const firstVisit = page.locator('.visit-item').first(); + await firstVisit.hover(); + await page.waitForTimeout(300); + await firstVisit.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + const secondVisit = page.locator('.visit-item').nth(1); + await secondVisit.hover(); + await page.waitForTimeout(300); + await secondVisit.locator('.visit-checkbox').click(); + await page.waitForTimeout(500); + + // Click merge button + const bulkActions = page.locator('.visit-bulk-actions'); + const mergeButton = bulkActions.locator('button').filter({ hasText: 'Merge' }); + await mergeButton.click(); + + // Wait for API call + await page.waitForTimeout(2000); + + // Verify flash message appears + const flashMessage = page.locator('.flash-message'); + await expect(flashMessage).toBeVisible({ timeout: 5000 }); + + // After merge, the visits should be combined into one + // So final count should be less than initial + const finalVisitCount = await page.locator('.visit-item').count(); + expect(finalVisitCount).toBeLessThan(visitCount); + }); + + test('should open and close panel without shifting controls', async ({ page }) => { + // Get the layer control element + const layerControl = page.locator('.leaflet-control-layers'); + await expect(layerControl).toBeVisible(); + + // Get initial position of the control + const initialBox = await layerControl.boundingBox(); + + // Open the drawer + await clickDrawerButton(page); + await page.waitForTimeout(500); + + // Verify drawer is open + const drawerOpen = await isDrawerOpen(page); + expect(drawerOpen).toBe(true); + + // Get position after opening - should be the same (no shifting) + const afterOpenBox = await layerControl.boundingBox(); + expect(afterOpenBox.x).toBe(initialBox.x); + expect(afterOpenBox.y).toBe(initialBox.y); + + // Close the drawer + await clickDrawerButton(page); + await page.waitForTimeout(500); + + // Verify drawer is closed + const drawerClosed = await isDrawerOpen(page); + expect(drawerClosed).toBe(false); + + // Get final position - should still be the same + const afterCloseBox = await layerControl.boundingBox(); + expect(afterCloseBox.x).toBe(initialBox.x); + expect(afterCloseBox.y).toBe(initialBox.y); + }); +}); diff --git a/e2e/map/map-suggested-visits.spec.js b/e2e/map/map-suggested-visits.spec.js new file mode 100644 index 00000000..0825ed3b --- /dev/null +++ b/e2e/map/map-suggested-visits.spec.js @@ -0,0 +1,296 @@ +import { test, expect } from '@playwright/test'; +import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js'; +import { waitForMap, enableLayer, clickSuggestedVisit } from '../helpers/map.js'; + +test.describe('Suggested Visit Interactions', () => { + test.beforeEach(async ({ page }) => { + await navigateToMap(page); + await waitForMap(page); + + // Navigate to a date range that includes visits (last month to now) + const toggleButton = page.locator('button[data-action*="map-controls#toggle"]'); + const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible(); + + if (!isPanelVisible) { + await toggleButton.click(); + await page.waitForTimeout(300); + } + + // Set date range to last month + await page.click('a:has-text("Last month")'); + await page.waitForTimeout(2000); + + await closeOnboardingModal(page); + await waitForMap(page); + + await enableLayer(page, 'Suggested Visits'); + await page.waitForTimeout(2000); + + // Pan map to ensure a visit marker is in viewport + await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.suggestedVisitCircles) { + const layers = controller.visitsManager.suggestedVisitCircles._layers; + const firstVisit = Object.values(layers)[0]; + if (firstVisit && firstVisit._latlng) { + controller.map.setView(firstVisit._latlng, 14); + } + } + }); + await page.waitForTimeout(1000); + }); + + test('should click on a suggested visit and open popup', async ({ page }) => { + // Debug: Check what visit circles exist + const allCircles = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.suggestedVisitCircles?._layers) { + const layers = controller.visitsManager.suggestedVisitCircles._layers; + return { + count: Object.keys(layers).length, + hasLayers: Object.keys(layers).length > 0 + }; + } + return { count: 0, hasLayers: false }; + }); + + // If we have visits in the layer but can't find DOM elements, use coordinates + if (!allCircles.hasLayers) { + console.log('No suggested visits found - skipping test'); + return; + } + + // Click on the visit using map coordinates + const visitClicked = await clickSuggestedVisit(page); + + if (!visitClicked) { + console.log('Could not click suggested visit - skipping test'); + return; + } + + await page.waitForTimeout(500); + + // Verify popup is visible + const popup = page.locator('.leaflet-popup'); + await expect(popup).toBeVisible(); + }); + + test('should display correct content in suggested visit popup', async ({ page }) => { + // Click visit programmatically + const visitClicked = await clickSuggestedVisit(page); + + if (!visitClicked) { + console.log('No suggested visits found - skipping test'); + return; + } + + await page.waitForTimeout(500); + + // Get popup content + const popupContent = page.locator('.leaflet-popup-content'); + await expect(popupContent).toBeVisible(); + + const content = await popupContent.textContent(); + + // Verify visit information is present + expect(content).toMatch(/Visit|Place|Duration|Started|Ended|Suggested/i); + }); + + test('should confirm suggested visit', async ({ page }) => { + // Click visit programmatically + const visitClicked = await clickSuggestedVisit(page); + + if (!visitClicked) { + console.log('No suggested visits found - skipping test'); + return; + } + + await page.waitForTimeout(500); + + // Look for confirm button in popup + const confirmButton = page.locator('.leaflet-popup-content button:has-text("Confirm")').first(); + const hasConfirmButton = await confirmButton.count() > 0; + + if (!hasConfirmButton) { + console.log('No confirm button found - skipping test'); + return; + } + + // Get initial counts for both suggested and confirmed visits + const initialCounts = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return { + suggested: controller?.visitsManager?.suggestedVisitCircles?._layers + ? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length + : 0, + confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers + ? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length + : 0 + }; + }); + + // Click confirm button + await confirmButton.click(); + await page.waitForTimeout(1500); + + // Verify the marker changed from yellow to green (suggested to confirmed) + const finalCounts = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + return { + suggested: controller?.visitsManager?.suggestedVisitCircles?._layers + ? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length + : 0, + confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers + ? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length + : 0 + }; + }); + + // Verify suggested visit count decreased + expect(finalCounts.suggested).toBeLessThan(initialCounts.suggested); + + // Verify confirmed visit count increased (marker changed from yellow to green) + expect(finalCounts.confirmed).toBeGreaterThan(initialCounts.confirmed); + + // Verify popup is closed after confirmation + const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false); + expect(popupVisible).toBe(false); + }); + + test('should decline suggested visit', async ({ page }) => { + // Click visit programmatically + const visitClicked = await clickSuggestedVisit(page); + + if (!visitClicked) { + console.log('No suggested visits found - skipping test'); + return; + } + + await page.waitForTimeout(500); + + // Look for decline button in popup + const declineButton = page.locator('.leaflet-popup-content button:has-text("Decline")').first(); + const hasDeclineButton = await declineButton.count() > 0; + + if (!hasDeclineButton) { + console.log('No decline button found - skipping test'); + return; + } + + // Get initial suggested visit count + const initialCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.suggestedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length; + } + return 0; + }); + + // Verify popup is visible before decline + await expect(page.locator('.leaflet-popup')).toBeVisible(); + + // Click decline button + await declineButton.click(); + await page.waitForTimeout(1500); + + // Verify popup is removed from map + const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false); + expect(popupVisible).toBe(false); + + // Verify marker is removed from map (suggested visit count decreased) + const finalCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.suggestedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length; + } + return 0; + }); + + expect(finalCount).toBeLessThan(initialCount); + + // Verify the yellow marker is no longer visible on the map + const yellowMarkerCount = await page.locator('.leaflet-interactive[stroke="#f59e0b"]').count(); + expect(yellowMarkerCount).toBeLessThan(initialCount); + }); + + test('should change place in dropdown for suggested visit', async ({ page }) => { + const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first(); + const hasVisits = await visitCircle.count() > 0; + + if (!hasVisits) { + console.log('No suggested visits found - skipping test'); + return; + } + + await visitCircle.click({ force: true }); + await page.waitForTimeout(500); + + // Look for place dropdown/select in popup + const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first(); + const hasPlaceDropdown = await placeSelect.count() > 0; + + if (!hasPlaceDropdown) { + console.log('No place dropdown found - skipping test'); + return; + } + + // Select a different option + await placeSelect.selectOption({ index: 1 }); + await page.waitForTimeout(300); + + // Verify the selection changed + const newValue = await placeSelect.inputValue(); + expect(newValue).toBeTruthy(); + }); + + test('should delete suggested visit from map', async ({ page }) => { + const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first(); + const hasVisits = await visitCircle.count() > 0; + + if (!hasVisits) { + console.log('No suggested visits found - skipping test'); + return; + } + + // Count initial visits + const initialVisitCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.suggestedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length; + } + return 0; + }); + + await visitCircle.click({ force: true }); + await page.waitForTimeout(500); + + // Find delete button + const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first(); + const hasDeleteButton = await deleteButton.count() > 0; + + if (!hasDeleteButton) { + console.log('No delete button found - skipping test'); + return; + } + + // Handle confirmation dialog + page.once('dialog', dialog => { + expect(dialog.message()).toMatch(/delete|remove/i); + dialog.accept(); + }); + + await deleteButton.click(); + await page.waitForTimeout(2000); + + // Verify visit count decreased + const finalVisitCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.suggestedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length; + } + return 0; + }); + + expect(finalVisitCount).toBeLessThan(initialVisitCount); + }); +}); diff --git a/e2e/map/map-visits.spec.js b/e2e/map/map-visits.spec.js new file mode 100644 index 00000000..4633b274 --- /dev/null +++ b/e2e/map/map-visits.spec.js @@ -0,0 +1,243 @@ +import { test, expect } from '@playwright/test'; +import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js'; +import { waitForMap, enableLayer, clickConfirmedVisit } from '../helpers/map.js'; + +test.describe('Visit Interactions', () => { + test.beforeEach(async ({ page }) => { + await navigateToMap(page); + await waitForMap(page); + + // Navigate to a date range that includes visits (last month to now) + const toggleButton = page.locator('button[data-action*="map-controls#toggle"]'); + const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible(); + + if (!isPanelVisible) { + await toggleButton.click(); + await page.waitForTimeout(300); + } + + // Set date range to last month + await page.click('a:has-text("Last month")'); + await page.waitForTimeout(2000); + + await closeOnboardingModal(page); + await waitForMap(page); + + await enableLayer(page, 'Confirmed Visits'); + await page.waitForTimeout(2000); + + // Pan map to ensure a visit marker is in viewport + await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.confirmedVisitCircles) { + const layers = controller.visitsManager.confirmedVisitCircles._layers; + const firstVisit = Object.values(layers)[0]; + if (firstVisit && firstVisit._latlng) { + controller.map.setView(firstVisit._latlng, 14); + } + } + }); + await page.waitForTimeout(1000); + }); + + test('should click on a confirmed visit and open popup', async ({ page }) => { + // Debug: Check what visit circles exist + const allCircles = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.confirmedVisitCircles?._layers) { + const layers = controller.visitsManager.confirmedVisitCircles._layers; + return { + count: Object.keys(layers).length, + hasLayers: Object.keys(layers).length > 0 + }; + } + return { count: 0, hasLayers: false }; + }); + + // If we have visits in the layer but can't find DOM elements, use coordinates + if (!allCircles.hasLayers) { + console.log('No confirmed visits found - skipping test'); + return; + } + + // Click on the visit using map coordinates + const visitClicked = await clickConfirmedVisit(page); + + if (!visitClicked) { + console.log('Could not click visit - skipping test'); + return; + } + + await page.waitForTimeout(500); + + // Verify popup is visible + const popup = page.locator('.leaflet-popup'); + await expect(popup).toBeVisible(); + }); + + test('should display correct content in confirmed visit popup', async ({ page }) => { + // Click visit programmatically + const visitClicked = await clickConfirmedVisit(page); + + if (!visitClicked) { + console.log('No confirmed visits found - skipping test'); + return; + } + + await page.waitForTimeout(500); + + // Get popup content + const popupContent = page.locator('.leaflet-popup-content'); + await expect(popupContent).toBeVisible(); + + const content = await popupContent.textContent(); + + // Verify visit information is present + expect(content).toMatch(/Visit|Place|Duration|Started|Ended/i); + }); + + test('should change place in dropdown and save', async ({ page }) => { + const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first(); + const hasVisits = await visitCircle.count() > 0; + + if (!hasVisits) { + console.log('No confirmed visits found - skipping test'); + return; + } + + await visitCircle.click({ force: true }); + await page.waitForTimeout(500); + + // Look for place dropdown/select in popup + const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first(); + const hasPlaceDropdown = await placeSelect.count() > 0; + + if (!hasPlaceDropdown) { + console.log('No place dropdown found - skipping test'); + return; + } + + // Get current value + const initialValue = await placeSelect.inputValue().catch(() => null); + + // Select a different option + await placeSelect.selectOption({ index: 1 }); + await page.waitForTimeout(300); + + // Find and click save button + const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first(); + const hasSaveButton = await saveButton.count() > 0; + + if (hasSaveButton) { + await saveButton.click(); + await page.waitForTimeout(1000); + + // Verify popup closes after successful save + const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false); + expect(popupVisible).toBe(false); + + // Verify success flash message appears + const flashMessage = page.locator('#flash-messages [role="alert"]'); + await expect(flashMessage).toBeVisible({ timeout: 2000 }); + const messageText = await flashMessage.textContent(); + expect(messageText).toContain('Visit updated successfully'); + } + }); + + test('should change visit name and save', async ({ page }) => { + const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first(); + const hasVisits = await visitCircle.count() > 0; + + if (!hasVisits) { + console.log('No confirmed visits found - skipping test'); + return; + } + + await visitCircle.click({ force: true }); + await page.waitForTimeout(500); + + // Look for name input field + const nameInput = page.locator('.leaflet-popup-content input[type="text"]').first(); + const hasNameInput = await nameInput.count() > 0; + + if (!hasNameInput) { + console.log('No name input found - skipping test'); + return; + } + + // Change the name + const newName = `Test Visit ${Date.now()}`; + await nameInput.fill(newName); + await page.waitForTimeout(300); + + // Find and click save button + const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first(); + const hasSaveButton = await saveButton.count() > 0; + + if (hasSaveButton) { + await saveButton.click(); + await page.waitForTimeout(1000); + + // Verify popup closes after successful save + const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false); + expect(popupVisible).toBe(false); + + // Verify success flash message appears + const flashMessage = page.locator('#flash-messages [role="alert"]'); + await expect(flashMessage).toBeVisible({ timeout: 2000 }); + const messageText = await flashMessage.textContent(); + expect(messageText).toContain('Visit updated successfully'); + } + }); + + test('should delete confirmed visit from map', async ({ page }) => { + const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first(); + const hasVisits = await visitCircle.count() > 0; + + if (!hasVisits) { + console.log('No confirmed visits found - skipping test'); + return; + } + + // Count initial visits + const initialVisitCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.confirmedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length; + } + return 0; + }); + + await visitCircle.click({ force: true }); + await page.waitForTimeout(500); + + // Find delete button + const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first(); + const hasDeleteButton = await deleteButton.count() > 0; + + if (!hasDeleteButton) { + console.log('No delete button found - skipping test'); + return; + } + + // Handle confirmation dialog + page.once('dialog', dialog => { + expect(dialog.message()).toMatch(/delete|remove/i); + dialog.accept(); + }); + + await deleteButton.click(); + await page.waitForTimeout(2000); + + // Verify visit count decreased + const finalVisitCount = await page.evaluate(() => { + const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps'); + if (controller?.visitsManager?.confirmedVisitCircles?._layers) { + return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length; + } + return 0; + }); + + expect(finalVisitCount).toBeLessThan(initialVisitCount); + }); +}); diff --git a/e2e/marker-factory.spec.js b/e2e/marker-factory.spec.js deleted file mode 100644 index be97e990..00000000 --- a/e2e/marker-factory.spec.js +++ /dev/null @@ -1,180 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Test to verify the marker factory refactoring is memory-safe - * and maintains consistent marker creation across different use cases - */ - -test.describe('Marker Factory Refactoring', () => { - let page; - let context; - - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - page = await context.newPage(); - - // Sign in - await page.goto('/users/sign_in'); - await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 }); - await page.fill('input[name="user[email]"]', 'demo@dawarich.app'); - await page.fill('input[name="user[password]"]', 'password'); - await page.click('input[type="submit"][value="Log in"]'); - await page.waitForURL('/map', { timeout: 10000 }); - }); - - test.afterAll(async () => { - await page.close(); - await context.close(); - }); - - test('should have marker factory available in bundled code', async () => { - // Navigate to map - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Check if marker factory functions are available in the bundled code - const factoryAnalysis = await page.evaluate(() => { - const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML); - const allJavaScript = scripts.join(' '); - - return { - hasMarkerFactory: allJavaScript.includes('marker_factory') || allJavaScript.includes('MarkerFactory'), - hasCreateLiveMarker: allJavaScript.includes('createLiveMarker'), - hasCreateInteractiveMarker: allJavaScript.includes('createInteractiveMarker'), - hasCreateStandardIcon: allJavaScript.includes('createStandardIcon'), - totalJSSize: allJavaScript.length, - scriptCount: scripts.length - }; - }); - - console.log('Marker factory analysis:', factoryAnalysis); - - // The refactoring should be present (though may not be detectable in bundled JS) - expect(factoryAnalysis.scriptCount).toBeGreaterThan(0); - expect(factoryAnalysis.totalJSSize).toBeGreaterThan(1000); - }); - - test('should maintain consistent marker styling across use cases', async () => { - // Navigate to map - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Check for consistent marker styling in the DOM - const markerConsistency = await page.evaluate(() => { - // Look for custom-div-icon markers (our standard marker style) - const customMarkers = document.querySelectorAll('.custom-div-icon'); - const markerStyles = Array.from(customMarkers).map(marker => { - const innerDiv = marker.querySelector('div'); - return { - hasInnerDiv: !!innerDiv, - backgroundColor: innerDiv?.style.backgroundColor || 'none', - borderRadius: innerDiv?.style.borderRadius || 'none', - width: innerDiv?.style.width || 'none', - height: innerDiv?.style.height || 'none' - }; - }); - - // Check if all markers have consistent styling - const hasConsistentStyling = markerStyles.every(style => - style.hasInnerDiv && - style.borderRadius === '50%' && - (style.backgroundColor === 'blue' || style.backgroundColor === 'orange') && - style.width === style.height // Should be square - ); - - return { - totalCustomMarkers: customMarkers.length, - markerStyles: markerStyles.slice(0, 3), // Show first 3 for debugging - hasConsistentStyling, - allMarkersCount: document.querySelectorAll('.leaflet-marker-icon').length - }; - }); - - console.log('Marker consistency analysis:', markerConsistency); - - // Verify consistent styling if markers are present - if (markerConsistency.totalCustomMarkers > 0) { - expect(markerConsistency.hasConsistentStyling).toBe(true); - } - - // Test always passes as we've verified implementation - expect(true).toBe(true); - }); - - test('should have memory-safe marker creation patterns', async () => { - // Navigate to map - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Monitor basic memory patterns - const memoryInfo = await page.evaluate(() => { - const memory = window.performance.memory; - return { - usedJSHeapSize: memory?.usedJSHeapSize || 0, - totalJSHeapSize: memory?.totalJSHeapSize || 0, - jsHeapSizeLimit: memory?.jsHeapSizeLimit || 0, - memoryAvailable: !!memory - }; - }); - - console.log('Memory info:', memoryInfo); - - // Verify memory monitoring is available and reasonable - if (memoryInfo.memoryAvailable) { - expect(memoryInfo.usedJSHeapSize).toBeGreaterThan(0); - expect(memoryInfo.usedJSHeapSize).toBeLessThan(memoryInfo.totalJSHeapSize); - } - - // Check for memory-safe patterns in the code structure - const codeSafetyAnalysis = await page.evaluate(() => { - return { - hasLeafletContainer: !!document.querySelector('.leaflet-container'), - hasMapElement: !!document.querySelector('#map'), - leafletLayerCount: document.querySelectorAll('.leaflet-layer').length, - markerPaneElements: document.querySelectorAll('.leaflet-marker-pane').length, - totalLeafletElements: document.querySelectorAll('[class*="leaflet"]').length - }; - }); - - console.log('Code safety analysis:', codeSafetyAnalysis); - - // Verify basic structure is sound - expect(codeSafetyAnalysis.hasLeafletContainer).toBe(true); - expect(codeSafetyAnalysis.hasMapElement).toBe(true); - expect(codeSafetyAnalysis.totalLeafletElements).toBeGreaterThan(10); - }); - - test('should demonstrate marker factory benefits', async () => { - // This test documents the benefits of the marker factory refactoring - - console.log('=== MARKER FACTORY REFACTORING BENEFITS ==='); - console.log(''); - console.log('1. ✅ CODE REUSE:'); - console.log(' - Single source of truth for marker styling'); - console.log(' - Consistent divIcon creation across all use cases'); - console.log(' - Reduced code duplication between markers.js and live_map_handler.js'); - console.log(''); - console.log('2. ✅ MEMORY SAFETY:'); - console.log(' - createLiveMarker(): Lightweight markers for live streaming'); - console.log(' - createInteractiveMarker(): Full-featured markers for static display'); - console.log(' - createStandardIcon(): Shared icon factory prevents object duplication'); - console.log(''); - console.log('3. ✅ MAINTENANCE:'); - console.log(' - Centralized marker logic in marker_factory.js'); - console.log(' - Easy to update styling across entire application'); - console.log(' - Clear separation between live and interactive marker features'); - console.log(''); - console.log('4. ✅ PERFORMANCE:'); - console.log(' - Live markers skip expensive drag handlers and popups'); - console.log(' - Interactive markers include full feature set only when needed'); - console.log(' - No shared object references that could cause memory leaks'); - console.log(''); - console.log('=== REFACTORING COMPLETE ==='); - - // Test always passes - this is documentation - expect(true).toBe(true); - }); -}); \ No newline at end of file diff --git a/e2e/memory-leak-fix.spec.js b/e2e/memory-leak-fix.spec.js deleted file mode 100644 index 735a4391..00000000 --- a/e2e/memory-leak-fix.spec.js +++ /dev/null @@ -1,140 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Test to verify the Live Mode memory leak fix - * This test focuses on verifying the fix works by checking DOM elements - * and memory patterns rather than requiring full controller integration - */ - -test.describe('Memory Leak Fix Verification', () => { - let page; - let context; - - test.beforeAll(async ({ browser }) => { - context = await browser.newContext(); - page = await context.newPage(); - - // Sign in - await page.goto('/users/sign_in'); - await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 }); - await page.fill('input[name="user[email]"]', 'demo@dawarich.app'); - await page.fill('input[name="user[password]"]', 'password'); - await page.click('input[type="submit"][value="Log in"]'); - await page.waitForURL('/map', { timeout: 10000 }); - }); - - test.afterAll(async () => { - await page.close(); - await context.close(); - }); - - test('should load map page with memory leak fix implemented', async () => { - // Navigate to map with test data - await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59'); - await page.waitForSelector('#map', { timeout: 10000 }); - await page.waitForSelector('.leaflet-container', { timeout: 10000 }); - - // Verify the updated appendPoint method exists and has the fix - const codeAnalysis = await page.evaluate(() => { - // Check if the maps controller exists and analyze its appendPoint method - const mapElement = document.querySelector('#map'); - const controllers = mapElement?._stimulus_controllers; - const mapController = controllers?.find(c => c.identifier === 'maps'); - - if (mapController && mapController.appendPoint) { - const methodString = mapController.appendPoint.toString(); - return { - hasController: true, - hasAppendPoint: true, - // Check for fixed patterns (absence of problematic code) - hasOldClearLayersPattern: methodString.includes('clearLayers()') && methodString.includes('L.layerGroup(this.markersArray)'), - hasOldPolylineRecreation: methodString.includes('createPolylinesLayer'), - // Check for new efficient patterns - hasIncrementalMarkerAdd: methodString.includes('this.markersLayer.addLayer(newMarker)'), - hasBoundedData: methodString.includes('> 1000'), - hasLastMarkerTracking: methodString.includes('this.lastMarkerRef'), - methodLength: methodString.length - }; - } - - return { - hasController: !!mapController, - hasAppendPoint: false, - controllerCount: controllers?.length || 0 - }; - }); - - console.log('Code analysis:', codeAnalysis); - - // The test passes if either: - // 1. Controller is found and shows the fix is implemented - // 2. Controller is not found (which is the current issue) but the code exists in the file - if (codeAnalysis.hasController && codeAnalysis.hasAppendPoint) { - // If controller is found, verify the fix - expect(codeAnalysis.hasOldClearLayersPattern).toBe(false); // Old inefficient pattern should be gone - expect(codeAnalysis.hasIncrementalMarkerAdd).toBe(true); // New efficient pattern should exist - expect(codeAnalysis.hasBoundedData).toBe(true); // Should have bounded data structures - } else { - // Controller not found (expected based on previous tests), but we've implemented the fix - console.log('Controller not found in test environment, but fix has been implemented in code'); - } - - // Verify basic map functionality - const mapState = await page.evaluate(() => { - return { - hasLeafletContainer: !!document.querySelector('.leaflet-container'), - leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length, - hasMapElement: !!document.querySelector('#map'), - mapHasDataController: document.querySelector('#map')?.hasAttribute('data-controller') - }; - }); - - expect(mapState.hasLeafletContainer).toBe(true); - expect(mapState.hasMapElement).toBe(true); - expect(mapState.mapHasDataController).toBe(true); - expect(mapState.leafletElementCount).toBeGreaterThan(10); // Should have substantial Leaflet elements - }); - - test('should have memory-efficient appendPoint implementation in source code', async () => { - // This test verifies the fix exists in the actual source file - // by checking the current page's loaded JavaScript - - const hasEfficientImplementation = await page.evaluate(() => { - // Try to access the source code through various means - const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML); - const allJavaScript = scripts.join(' '); - - // Check for key improvements (these should exist in the bundled JS) - const hasIncrementalAdd = allJavaScript.includes('addLayer(newMarker)'); - const hasBoundedArrays = allJavaScript.includes('length > 1000'); - const hasEfficientTracking = allJavaScript.includes('lastMarkerRef'); - - // Check that old inefficient patterns are not present together - const hasOldPattern = allJavaScript.includes('clearLayers()') && - allJavaScript.includes('addLayer(L.layerGroup(this.markersArray))'); - - return { - hasIncrementalAdd, - hasBoundedArrays, - hasEfficientTracking, - hasOldPattern, - scriptCount: scripts.length, - totalJSSize: allJavaScript.length - }; - }); - - console.log('Source code analysis:', hasEfficientImplementation); - - // We expect the fix to be present in the bundled JavaScript - // Note: These might not be detected if the JS is minified/bundled differently - console.log('Memory leak fix has been implemented in maps_controller.js'); - console.log('Key improvements:'); - console.log('- Incremental marker addition instead of layer recreation'); - console.log('- Bounded data structures (1000 point limit)'); - console.log('- Efficient last marker tracking'); - console.log('- Incremental polyline updates'); - - // Test passes regardless as we've verified the fix is in the source code - expect(true).toBe(true); - }); -}); \ No newline at end of file diff --git a/e2e/setup/auth.setup.js b/e2e/setup/auth.setup.js new file mode 100644 index 00000000..72f486dd --- /dev/null +++ b/e2e/setup/auth.setup.js @@ -0,0 +1,24 @@ +import { test as setup, expect } from '@playwright/test'; + +const authFile = 'e2e/temp/.auth/user.json'; + +setup('authenticate', async ({ page }) => { + // Navigate to login page with more lenient waiting + await page.goto('/users/sign_in', { + waitUntil: 'domcontentloaded', + timeout: 30000 + }); + + // Fill in credentials + await page.fill('input[name="user[email]"]', 'demo@dawarich.app'); + await page.fill('input[name="user[password]"]', 'password'); + + // Click login button + await page.click('input[type="submit"][value="Log in"]'); + + // Wait for successful navigation + await page.waitForURL('/map', { timeout: 10000 }); + + // Save authentication state + await page.context().storageState({ path: authFile }); +}); diff --git a/playwright.config.js b/playwright.config.js index 8057408f..64657c6f 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -23,27 +23,42 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: process.env.BASE_URL || 'http://localhost:3000', + /* Use European locale and timezone */ + locale: 'en-GB', + timezoneId: 'Europe/Berlin', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', - + /* Take screenshot on failure */ screenshot: 'only-on-failure', - + /* Record video on failure */ video: 'retain-on-failure', }, /* Configure projects for major browsers */ projects: [ + // Setup project - runs authentication before all tests + { + name: 'setup', + testMatch: /.*\/setup\/auth\.setup\.js/ + }, + { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + // Use saved authentication state + storageState: 'e2e/temp/.auth/user.json' + }, + dependencies: ['setup'], }, ], /* Run your local dev server before starting the tests */ webServer: { - command: 'RAILS_ENV=test rails server -p 3000', + command: 'OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES RAILS_ENV=test rails server -p 3000', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, diff --git a/postgresql.conf.example b/postgresql.conf.example deleted file mode 100644 index 9e1687c3..00000000 --- a/postgresql.conf.example +++ /dev/null @@ -1,36 +0,0 @@ -listen_addresses = '*' -max_connections = 50 - -shared_buffers = 512MB - -work_mem = 128MB -maintenance_work_mem = 128MB - - -dynamic_shared_memory_type = posix -checkpoint_timeout = 10min # range 30s-1d -max_wal_size = 2GB -min_wal_size = 80MB -max_parallel_workers_per_gather = 4 - -log_min_duration_statement = 500 # -1 is disabled, 0 logs all statements - # -1 disables, 0 logs all temp files -log_timezone = 'UTC' - - -autovacuum_vacuum_scale_factor = 0.05 # fraction of table size before vacuum -autovacuum_analyze_scale_factor = 0.05 # fraction of table size before analyze - - -datestyle = 'iso, dmy' - -timezone = 'UTC' - -lc_messages = 'en_US.utf8' # locale for system error message - # strings -lc_monetary = 'en_US.utf8' # locale for monetary formatting -lc_numeric = 'en_US.utf8' # locale for number formatting -lc_time = 'en_US.utf8' # locale for time formatting - - -default_text_search_config = 'pg_catalog.english' diff --git a/spec/fixtures/files/kml/extended_data.kml b/spec/fixtures/files/kml/extended_data.kml new file mode 100644 index 00000000..ac72a22f --- /dev/null +++ b/spec/fixtures/files/kml/extended_data.kml @@ -0,0 +1,27 @@ + + + + Extended Data Example + + Location with Speed + A location with extended data including speed + + 2024-01-19T11:30:00Z + + + -122.0841,37.4220,10 + + + + 5.5 + + + 10 + + + 85 + + + + + diff --git a/spec/fixtures/files/kml/gx_track.kml b/spec/fixtures/files/kml/gx_track.kml new file mode 100644 index 00000000..1fee0a85 --- /dev/null +++ b/spec/fixtures/files/kml/gx_track.kml @@ -0,0 +1,19 @@ + + + + Google Earth Track + + GPS Track + + 2024-01-20T08:00:00Z + 2024-01-20T08:01:00Z + 2024-01-20T08:02:00Z + 2024-01-20T08:03:00Z + -122.0841 37.4220 10 + -122.0851 37.4230 12 + -122.0861 37.4240 14 + -122.0871 37.4250 16 + + + + diff --git a/spec/fixtures/files/kml/invalid_coordinates.kml b/spec/fixtures/files/kml/invalid_coordinates.kml new file mode 100644 index 00000000..461b9ce9 --- /dev/null +++ b/spec/fixtures/files/kml/invalid_coordinates.kml @@ -0,0 +1,24 @@ + + + + Invalid Coordinates + + No Coordinates + + 2024-01-23T10:00:00Z + + + + + + + Only Longitude + + 2024-01-23T11:00:00Z + + + -122.0841 + + + + diff --git a/spec/fixtures/files/kml/large_track.kml b/spec/fixtures/files/kml/large_track.kml new file mode 100644 index 00000000..462455a6 --- /dev/null +++ b/spec/fixtures/files/kml/large_track.kml @@ -0,0 +1,46 @@ + + + + Large Track for Batch Testing + + Long Track + + 2024-01-25T00:00:00Z + + + + -122.0841,37.4220,10 + -122.0842,37.4221,10 + -122.0843,37.4222,10 + -122.0844,37.4223,10 + -122.0845,37.4224,10 + -122.0846,37.4225,10 + -122.0847,37.4226,10 + -122.0848,37.4227,10 + -122.0849,37.4228,10 + -122.0850,37.4229,10 + + + + + Another Long Track + + 2024-01-25T12:00:00Z + + + + -122.0851,37.4230,12 + -122.0852,37.4231,12 + -122.0853,37.4232,12 + -122.0854,37.4233,12 + -122.0855,37.4234,12 + -122.0856,37.4235,12 + -122.0857,37.4236,12 + -122.0858,37.4237,12 + -122.0859,37.4238,12 + -122.0860,37.4239,12 + + + + + diff --git a/spec/fixtures/files/kml/linestring_track.kml b/spec/fixtures/files/kml/linestring_track.kml new file mode 100644 index 00000000..52d3fb07 --- /dev/null +++ b/spec/fixtures/files/kml/linestring_track.kml @@ -0,0 +1,21 @@ + + + + LineString Track + + My Track + + 2024-01-16T10:00:00Z + + + + -122.0841,37.4220,10 + -122.0851,37.4230,12 + -122.0861,37.4240,14 + -122.0871,37.4250,16 + -122.0881,37.4260,18 + + + + + diff --git a/spec/fixtures/files/kml/multigeometry.kml b/spec/fixtures/files/kml/multigeometry.kml new file mode 100644 index 00000000..57d53823 --- /dev/null +++ b/spec/fixtures/files/kml/multigeometry.kml @@ -0,0 +1,28 @@ + + + + MultiGeometry Example + + Multiple Geometries + + 2024-01-18T15:00:00Z + + + + -122.0841,37.4220,10 + + + -122.0851,37.4230,12 + + + + -122.0861,37.4240,14 + -122.0871,37.4250,16 + -122.0881,37.4260,18 + -122.0891,37.4270,20 + + + + + + diff --git a/spec/fixtures/files/kml/nested_folders.kml b/spec/fixtures/files/kml/nested_folders.kml new file mode 100644 index 00000000..9ee0f0ad --- /dev/null +++ b/spec/fixtures/files/kml/nested_folders.kml @@ -0,0 +1,51 @@ + + + + Nested Folders + + Trip 1 + + Start Point + + 2024-01-21T08:00:00Z + + + -122.0841,37.4220,10 + + + + Day 1 + + Checkpoint 1 + + 2024-01-21T12:00:00Z + + + -122.0851,37.4230,12 + + + + + + Trip 2 + + Location A + + 2024-01-22T10:00:00Z + + + -122.0861,37.4240,14 + + + + Location B + + 2024-01-22T14:00:00Z + + + -122.0871,37.4250,16 + + + + + diff --git a/spec/fixtures/files/kml/points_with_timestamps.kml b/spec/fixtures/files/kml/points_with_timestamps.kml new file mode 100644 index 00000000..ec240f12 --- /dev/null +++ b/spec/fixtures/files/kml/points_with_timestamps.kml @@ -0,0 +1,33 @@ + + + + Points with Timestamps + + Location 1 + + 2024-01-15T12:00:00Z + + + -122.0841,37.4220,10 + + + + Location 2 + + 2024-01-15T13:00:00Z + + + -122.0851,37.4230,15 + + + + Location 3 + + 2024-01-15T14:00:00Z + + + -122.0861,37.4240,20 + + + + diff --git a/spec/fixtures/files/kml/timespan.kml b/spec/fixtures/files/kml/timespan.kml new file mode 100644 index 00000000..6959808e --- /dev/null +++ b/spec/fixtures/files/kml/timespan.kml @@ -0,0 +1,16 @@ + + + + TimeSpan Example + + Visit Duration + + 2024-01-10T09:00:00Z + 2024-01-10T17:00:00Z + + + -122.0841,37.4220,10 + + + + diff --git a/spec/jobs/family/invitations/sending_job_spec.rb b/spec/jobs/family/invitations/sending_job_spec.rb new file mode 100644 index 00000000..b3fc706c --- /dev/null +++ b/spec/jobs/family/invitations/sending_job_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::Invitations::SendingJob, type: :job do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let(:invitation) { create(:family_invitation, family: family, invited_by: user, status: :pending) } + + describe '#perform' do + context 'when invitation exists and is pending' do + it 'sends the invitation email' do + mailer_double = double('mailer') + expect(FamilyMailer).to receive(:invitation).with(invitation).and_return(mailer_double) + expect(mailer_double).to receive(:deliver_now) + + described_class.perform_now(invitation.id) + end + end + + context 'when invitation does not exist' do + it 'does not raise an error' do + expect do + described_class.perform_now(999_999) + end.not_to raise_error + end + + it 'does not send any email' do + expect(FamilyMailer).not_to receive(:invitation) + + described_class.perform_now(999_999) + end + end + + context 'when invitation is not pending' do + let(:accepted_invitation) do + create(:family_invitation, family: family, invited_by: user, status: :accepted) + end + + it 'does not send the invitation email' do + expect(FamilyMailer).not_to receive(:invitation) + + described_class.perform_now(accepted_invitation.id) + end + end + + context 'when invitation is cancelled' do + let(:cancelled_invitation) do + create(:family_invitation, family: family, invited_by: user, status: :cancelled) + end + + it 'does not send the invitation email' do + expect(FamilyMailer).not_to receive(:invitation) + + described_class.perform_now(cancelled_invitation.id) + end + end + + context 'integration test' do + before do + ActionMailer::Base.deliveries.clear + # Set a from address for the mailer to avoid SMTP errors + allow(ActionMailer::Base).to receive(:default).and_return(from: 'noreply@dawarich.app') + end + + it 'actually calls the mailer' do + mailer = instance_double(ActionMailer::MessageDelivery) + allow(FamilyMailer).to receive(:invitation).and_return(mailer) + allow(mailer).to receive(:deliver_now) + + described_class.perform_now(invitation.id) + + expect(FamilyMailer).to have_received(:invitation).with(invitation) + expect(mailer).to have_received(:deliver_now) + end + end + end +end diff --git a/spec/models/family_spec.rb b/spec/models/family_spec.rb index 7f81b898..215372f6 100644 --- a/spec/models/family_spec.rb +++ b/spec/models/family_spec.rb @@ -26,31 +26,100 @@ RSpec.describe Family, type: :model do describe '#can_add_members?' do let(:family) { create(:family, creator: user) } - context 'when family has fewer than max members' do + context 'when not in self-hosted mode' do before do - create(:family_membership, family: family, user: user, role: :owner) - create_list(:family_membership, 3, family: family, role: :member) + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) end - it 'returns true' do - expect(family.can_add_members?).to be true + context 'when family has fewer than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + end + + it 'returns true' do + expect(family.can_add_members?).to be true + end + end + + context 'when family has max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + end + + it 'returns false' do + expect(family.can_add_members?).to be false + end + end + + context 'when family has pending invitations that would reach max' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + create(:family_invitation, family: family, invited_by: user, status: :pending) + end + + it 'returns false' do + expect(family.can_add_members?).to be false + end + end + + context 'when family has no members' do + it 'returns true' do + expect(family.can_add_members?).to be true + end end end - context 'when family has max members' do + context 'when in self-hosted mode' do before do - create(:family_membership, family: family, user: user, role: :owner) - create_list(:family_membership, 4, family: family, role: :member) + allow(DawarichSettings).to receive(:self_hosted?).and_return(true) end - it 'returns false' do - expect(family.can_add_members?).to be false - end - end + context 'when family has fewer than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + end - context 'when family has no members' do - it 'returns true' do - expect(family.can_add_members?).to be true + it 'returns true' do + expect(family.can_add_members?).to be true + end + end + + context 'when family has max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + end + + it 'returns true (no limit in self-hosted mode)' do + expect(family.can_add_members?).to be true + end + end + + context 'when family has more than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 10, family: family, role: :member) + end + + it 'returns true (no limit in self-hosted mode)' do + expect(family.can_add_members?).to be true + end + end + + context 'when family has pending invitations that would exceed max' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + create_list(:family_invitation, 5, family: family, invited_by: user, status: :pending) + end + + it 'returns true (no limit in self-hosted mode)' do + expect(family.can_add_members?).to be true + end end end end @@ -122,4 +191,99 @@ RSpec.describe Family, type: :model do expect(Family::Membership.find_by(id: membership.id)).to be_nil end end + + describe '#full?' do + let(:family) { create(:family, creator: user) } + + context 'when not in self-hosted mode' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + end + + context 'when family has fewer than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + end + + it 'returns false' do + expect(family.full?).to be false + end + end + + context 'when family has exactly max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + end + + it 'returns true' do + expect(family.full?).to be true + end + end + + context 'when family has pending invitations that would reach max' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + create(:family_invitation, family: family, invited_by: user, status: :pending) + end + + it 'returns true' do + expect(family.full?).to be true + end + end + end + + context 'when in self-hosted mode' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(true) + end + + context 'when family has fewer than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + end + + it 'returns false' do + expect(family.full?).to be false + end + end + + context 'when family has exactly max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + end + + it 'returns false (no limit in self-hosted mode)' do + expect(family.full?).to be false + end + end + + context 'when family has more than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 10, family: family, role: :member) + end + + it 'returns false (no limit in self-hosted mode)' do + expect(family.full?).to be false + end + end + + context 'when family has pending invitations that would exceed max' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + create_list(:family_invitation, 5, family: family, invited_by: user, status: :pending) + end + + it 'returns false (no limit in self-hosted mode)' do + expect(family.full?).to be false + end + end + end + end end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index 2a90c2aa..3078009a 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -115,7 +115,8 @@ RSpec.describe Import, type: :model do immich_api: 5, geojson: 6, photoprism_api: 7, - user_data_archive: 8 + user_data_archive: 8, + kml: 9 ) end end diff --git a/spec/requests/api/v1/points_spec.rb b/spec/requests/api/v1/points_spec.rb index 50d3397e..4880daab 100644 --- a/spec/requests/api/v1/points_spec.rb +++ b/spec/requests/api/v1/points_spec.rb @@ -198,4 +198,113 @@ RSpec.describe 'Api::V1::Points', type: :request do end end end + + describe 'DELETE /bulk_destroy' do + let(:point_ids) { points.first(5).map(&:id) } + + it 'returns a successful response' do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: } + + expect(response).to have_http_status(:ok) + end + + it 'deletes multiple points' do + expect do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: } + end.to change { user.points.count }.by(-5) + end + + it 'returns the count of deleted points' do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: } + + json_response = JSON.parse(response.body) + + expect(json_response['message']).to eq('Points were successfully destroyed') + expect(json_response['count']).to eq(5) + end + + it 'only deletes points belonging to the current user' do + other_user = create(:user) + other_points = create_list(:point, 3, user: other_user) + all_point_ids = point_ids + other_points.map(&:id) + + expect do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: all_point_ids } + end.to change { user.points.count }.by(-5) + .and change { other_user.points.count }.by(0) + end + + context 'when no point_ids are provided' do + it 'returns success with zero count' do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: [] } + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['count']).to eq(0) + end + end + + context 'when point_ids parameter is missing' do + it 'returns an error' do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}" + + expect(response).to have_http_status(:unprocessable_entity) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('No points selected') + end + end + + context 'when user is inactive' do + before do + user.update(status: :inactive, active_until: 1.day.ago) + end + + it 'returns an unauthorized response' do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: } + + expect(response).to have_http_status(:unauthorized) + end + + it 'does not delete any points' do + expect do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: } + end.not_to(change { user.points.count }) + end + end + + context 'when deleting all user points' do + it 'successfully deletes all points' do + all_point_ids = points.map(&:id) + + expect do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: all_point_ids } + end.to change { user.points.count }.from(15).to(0) + end + end + + context 'when some point_ids do not exist' do + it 'deletes only existing points' do + non_existent_ids = [999_999, 888_888] + mixed_ids = point_ids + non_existent_ids + + expect do + delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}", + params: { point_ids: mixed_ids } + end.to change { user.points.count }.by(-5) + + json_response = JSON.parse(response.body) + expect(json_response['count']).to eq(5) + end + end + end end diff --git a/spec/requests/users/registrations_spec.rb b/spec/requests/users/registrations_spec.rb index 96b1469f..726c2ac3 100644 --- a/spec/requests/users/registrations_spec.rb +++ b/spec/requests/users/registrations_spec.rb @@ -51,8 +51,8 @@ RSpec.describe 'Users::Registrations', type: :request do get new_user_registration_path expect(response).to have_http_status(:ok) - expect(response.body).to include('Register now!') - expect(response.body).to include('take control over your location data') + expect(response.body).to include('Almost there!') + expect(response.body).to include('control over your location data') expect(response.body).not_to include('Join') expect(response.body).to include('Sign up') end @@ -227,7 +227,7 @@ RSpec.describe 'Users::Registrations', type: :request do get new_user_registration_path expect(response).to have_http_status(:ok) - expect(response.body).to include('Register now!') + expect(response.body).to include('Almost there!') end it 'allows account creation' do @@ -325,4 +325,243 @@ RSpec.describe 'Users::Registrations', type: :request do end end end + + describe 'Validation Error Handling' do + context 'when trying to register with an existing email' do + let!(:existing_user) { create(:user, email: 'existing@example.com') } + + it 'renders the registration form with error message' do + post user_registration_path, params: { + user: { + email: existing_user.email, + password: 'password123', + password_confirmation: 'password123' + } + } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Email has already been taken') + expect(response.body).to include('error_explanation') + end + + it 'does not create a new user' do + expect do + post user_registration_path, params: { + user: { + email: existing_user.email, + password: 'password123', + password_confirmation: 'password123' + } + } + end.not_to change(User, :count) + end + end + + context 'when password is too short' do + it 'renders the registration form with error message' do + post user_registration_path, params: { + user: { + email: 'newuser@example.com', + password: 'short', + password_confirmation: 'short' + } + } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include('Password is too short') + expect(response.body).to include('error_explanation') + end + end + + context 'when passwords do not match' do + it 'renders the registration form with error message' do + post user_registration_path, params: { + user: { + email: 'newuser@example.com', + password: 'password123', + password_confirmation: 'different123' + } + } + + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("Password confirmation doesn") + expect(response.body).to include('error_explanation') + end + end + end + + describe 'UTM Parameter Tracking' do + let(:utm_params) do + { + utm_source: 'google', + utm_medium: 'cpc', + utm_campaign: 'winter_2025', + utm_term: 'location_tracking', + utm_content: 'banner_ad' + } + end + + context 'when self-hosted mode is disabled' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + end + + it 'captures UTM parameters from registration page URL' do + get new_user_registration_path, params: utm_params + + expect(response).to have_http_status(:ok) + expect(session[:utm_source]).to eq('google') + expect(session[:utm_medium]).to eq('cpc') + expect(session[:utm_campaign]).to eq('winter_2025') + expect(session[:utm_term]).to eq('location_tracking') + expect(session[:utm_content]).to eq('banner_ad') + end + + it 'stores UTM parameters in user record after registration' do + # Visit registration page with UTM params + get new_user_registration_path, params: utm_params + + # Create account + unique_email = "utm-user-#{Time.current.to_i}@example.com" + post user_registration_path, params: { + user: { + email: unique_email, + password: 'password123', + password_confirmation: 'password123' + } + } + + # Verify UTM params were saved to user + user = User.find_by(email: unique_email) + expect(user.utm_source).to eq('google') + expect(user.utm_medium).to eq('cpc') + expect(user.utm_campaign).to eq('winter_2025') + expect(user.utm_term).to eq('location_tracking') + expect(user.utm_content).to eq('banner_ad') + end + + it 'clears UTM parameters from session after registration' do + # Visit registration page with UTM params + get new_user_registration_path, params: utm_params + + # Create account + unique_email = "utm-cleanup-#{Time.current.to_i}@example.com" + post user_registration_path, params: { + user: { + email: unique_email, + password: 'password123', + password_confirmation: 'password123' + } + } + + # Verify session was cleaned up + expect(session[:utm_source]).to be_nil + expect(session[:utm_medium]).to be_nil + expect(session[:utm_campaign]).to be_nil + expect(session[:utm_term]).to be_nil + expect(session[:utm_content]).to be_nil + end + + it 'handles partial UTM parameters' do + partial_utm = { utm_source: 'twitter', utm_campaign: 'spring_promo' } + + get new_user_registration_path, params: partial_utm + + unique_email = "partial-utm-#{Time.current.to_i}@example.com" + post user_registration_path, params: { + user: { + email: unique_email, + password: 'password123', + password_confirmation: 'password123' + } + } + + user = User.find_by(email: unique_email) + expect(user.utm_source).to eq('twitter') + expect(user.utm_campaign).to eq('spring_promo') + expect(user.utm_medium).to be_nil + expect(user.utm_term).to be_nil + expect(user.utm_content).to be_nil + end + + it 'does not store empty UTM parameters' do + empty_utm = { + utm_source: '', + utm_medium: '', + utm_campaign: 'campaign_only' + } + + get new_user_registration_path, params: empty_utm + + unique_email = "empty-utm-#{Time.current.to_i}@example.com" + post user_registration_path, params: { + user: { + email: unique_email, + password: 'password123', + password_confirmation: 'password123' + } + } + + user = User.find_by(email: unique_email) + expect(user.utm_source).to be_nil + expect(user.utm_medium).to be_nil + expect(user.utm_campaign).to eq('campaign_only') + end + + it 'works with family invitations' do + get new_user_registration_path, params: utm_params.merge(invitation_token: invitation.token) + + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + + user = User.find_by(email: invitation.email) + expect(user.utm_source).to eq('google') + expect(user.utm_campaign).to eq('winter_2025') + expect(user.family).to eq(family) + end + end + + context 'when self-hosted mode is enabled' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true') + end + + it 'does not capture UTM parameters' do + # With valid invitation to allow registration in self-hosted mode + get new_user_registration_path, params: utm_params.merge(invitation_token: invitation.token) + + expect(session[:utm_source]).to be_nil + expect(session[:utm_medium]).to be_nil + expect(session[:utm_campaign]).to be_nil + end + + it 'does not store UTM parameters in user record' do + # With valid invitation to allow registration in self-hosted mode + get new_user_registration_path, params: utm_params.merge(invitation_token: invitation.token) + + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + + user = User.find_by(email: invitation.email) + expect(user.utm_source).to be_nil + expect(user.utm_medium).to be_nil + expect(user.utm_campaign).to be_nil + expect(user.utm_term).to be_nil + expect(user.utm_content).to be_nil + end + end + end end diff --git a/spec/serializers/points/gpx_serializer_spec.rb b/spec/serializers/points/gpx_serializer_spec.rb index 7445862d..b931121a 100644 --- a/spec/serializers/points/gpx_serializer_spec.rb +++ b/spec/serializers/points/gpx_serializer_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Points::GpxSerializer do let(:points) do (1..3).map do |i| - create(:point, timestamp: 1.day.ago + i.minutes) + create(:point, timestamp: 1.day.ago + i.minutes, velocity: i * 10.5, course: i * 45.2) end end @@ -16,17 +16,55 @@ RSpec.describe Points::GpxSerializer do expect(serializer).to be_a(GPX::GPXFile) end - it 'includes waypoints' do - expect(serializer.tracks[0].points.size).to eq(3) + it 'includes waypoints in XML output' do + gpx_xml = serializer.to_s + + # Check that all 3 points are included in XML + expect(gpx_xml.scan(/#{point.altitude.to_f}") + end + end + + it 'includes speed and course data in the GPX XML output' do + gpx_xml = serializer.to_s + + # Check that speed is included in XML for points with velocity + expect(gpx_xml).to include('10.5') + expect(gpx_xml).to include('21.0') + expect(gpx_xml).to include('31.5') + + # Check that course is included in extensions for points with course data + expect(gpx_xml).to include('45.2') + expect(gpx_xml).to include('90.4') + expect(gpx_xml).to include('135.6') end - it 'includes waypoints with correct attributes' do - serializer.tracks[0].points.each_with_index do |track_point, index| - point = points[index] + context 'when points have nil velocity or course' do + let(:points) do + [ + create(:point, timestamp: 1.day.ago, velocity: nil, course: nil), + create(:point, timestamp: 1.day.ago + 1.minute, velocity: 15.5, course: nil), + create(:point, timestamp: 1.day.ago + 2.minutes, velocity: nil, course: 90.0) + ] + end - expect(track_point.lat.to_s).to eq(point.lat.to_s) - expect(track_point.lon.to_s).to eq(point.lon.to_s) - expect(track_point.time).to eq(point.recorded_at) + it 'handles nil values gracefully in XML output' do + gpx_xml = serializer.to_s + + # Should only include speed for the point with velocity + expect(gpx_xml).to include('15.5') + expect(gpx_xml).not_to include('0') # Should not include zero/nil speeds + + # Should only include course for the point with course data + expect(gpx_xml).to include('90.0') + + # Should have 3 track points total + expect(gpx_xml.scan(/ 'km' or 'mi' -``` - -#### Polyline Popup Testing Strategy -Due to the complexity of triggering JavaScript hover events on canvas elements in headless browsers, the tests use a multi-layered approach: - -1. **Primary**: JavaScript-based canvas hover simulation -2. **Secondary**: Direct polyline element interaction -3. **Fallback**: Map click interaction -4. **Validation**: Settings and data structure verification - -Even when popup interaction cannot be triggered in the test environment, the tests still validate: -- User settings are correctly configured -- Map loads with proper data attributes -- Polylines are present and properly structured -- Distance units are correctly set for both km and miles - -### Usage - -Run all map interaction tests: -```bash -bundle exec rspec spec/system/map_interaction_spec.rb -``` - -Run specific test groups: -```bash -# Polyline popup tests only -bundle exec rspec spec/system/map_interaction_spec.rb -e "polyline popup content" - -# Layer control tests only -bundle exec rspec spec/system/map_interaction_spec.rb -e "layer controls" -``` - -### Future Enhancements - -The test suite is designed to be easily extensible for: -- Additional map interaction features -- New distance units or measurement systems -- Enhanced popup content validation -- More complex user interaction scenarios diff --git a/spec/system/authentication_spec.rb b/spec/system/authentication_spec.rb deleted file mode 100644 index 42786fae..00000000 --- a/spec/system/authentication_spec.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'Authentication UI', type: :system do - let(:user) { create(:user, password: 'password123') } - - before do - stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') - .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) - - # Configure email for testing - ActionMailer::Base.default_options = { from: 'test@example.com' } - ActionMailer::Base.delivery_method = :test - ActionMailer::Base.perform_deliveries = true - ActionMailer::Base.deliveries.clear - end - - describe 'Account UI' do - it 'shows the user email in the UI when signed in' do - sign_in_user(user) - - expect(page).to have_current_path(map_path) - expect(page).to have_css('summary', text: user.email) - end - end - - describe 'Self-hosted UI' do - context 'when self-hosted mode is enabled' do - before do - allow(DawarichSettings).to receive(:self_hosted?).and_return(true) - stub_const('SELF_HOSTED', true) - end - - it 'does not show registration links in the login UI' do - visit new_user_session_path - - expect(page).not_to have_link('Register') - expect(page).not_to have_link('Sign up') - expect(page).not_to have_content('Register a new account') - end - end - end -end diff --git a/spec/system/map_interaction_spec.rb b/spec/system/map_interaction_spec.rb deleted file mode 100644 index 43dc9e41..00000000 --- a/spec/system/map_interaction_spec.rb +++ /dev/null @@ -1,923 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'Map Interaction', type: :system do - let(:user) { create(:user, password: 'password123') } - - before do - # Stub the GitHub API call to avoid external dependencies - stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') - .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) - end - - let!(:points) do - # Create a series of points that form a route - [ - create(:point, user: user, - lonlat: 'POINT(13.404954 52.520008)', - timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), - create(:point, user: user, - lonlat: 'POINT(13.405954 52.521008)', - timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), - create(:point, user: user, - lonlat: 'POINT(13.406954 52.522008)', - timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), - create(:point, user: user, - lonlat: 'POINT(13.407954 52.523008)', - timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) - ] - end - - describe 'Map page interaction' do - context 'when user is signed in' do - include_context 'authenticated map user' - include_examples 'map basic functionality' - include_examples 'map controls' - end - - context 'zoom functionality' do - include_context 'authenticated map user' - - it 'allows zoom in and zoom out functionality' do - # Test zoom controls are clickable and functional - zoom_in_button = find('.leaflet-control-zoom-in') - zoom_out_button = find('.leaflet-control-zoom-out') - - # Verify buttons are enabled and clickable - expect(zoom_in_button).to be_visible - expect(zoom_out_button).to be_visible - - # Click zoom in button multiple times and verify it works - 3.times do - zoom_in_button.click - sleep 0.5 - end - - # Click zoom out button multiple times and verify it works - 3.times do - zoom_out_button.click - sleep 0.5 - end - - # Verify zoom controls are still present and functional - expect(page).to have_css('.leaflet-control-zoom-in') - expect(page).to have_css('.leaflet-control-zoom-out') - end - end - - context 'settings panel' do - include_context 'authenticated map user' - - it 'opens and closes settings panel with cog button' do - # Find and click the settings (cog) button - it's created dynamically by the controller - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - # Verify settings panel opens - expect(page).to have_css('.leaflet-settings-panel', visible: true) - - # Click settings button again to close - settings_button.click - - # Verify settings panel closes - expect(page).not_to have_css('.leaflet-settings-panel', visible: true) - end - end - - context 'layer controls' do - include_context 'authenticated map user' - include_examples 'expandable layer control' - - it 'allows changing map layers between OpenStreetMap and OpenTopo' do - expand_layer_control - test_base_layer_switching - collapse_layer_control - end - - it 'allows enabling and disabling map layers' do - expand_layer_control - - MapLayerHelpers::OVERLAY_LAYERS.each do |layer_name| - test_layer_toggle(layer_name) - end - end - end - - context 'calendar panel' do - include_context 'authenticated map user' - - it 'has functional calendar button' do - # Find the calendar button (📅 emoji button) - calendar_button = find('.toggle-panel-button', wait: 10) - - # Verify button exists and has correct content - expect(calendar_button).to be_present - expect(calendar_button.text).to eq('📅') - - # Verify button is clickable (doesn't raise errors) - expect { calendar_button.click }.not_to raise_error - sleep 1 - - # Try clicking again to test toggle functionality - expect { calendar_button.click }.not_to raise_error - sleep 1 - - # The calendar panel JavaScript interaction is complex and may not work - # reliably in headless test environment, but the button should be functional - puts 'Note: Calendar button is functional. Panel interaction may require manual testing.' - end - end - - context 'map information display' do - include_context 'authenticated map user' - - it 'displays map statistics and scale' do - # Check for stats control (distance and points count) - expect(page).to have_css('.leaflet-control-stats', wait: 10) - stats_text = find('.leaflet-control-stats').text - - # Verify it contains distance and points information - expect(stats_text).to match(/\d+\.?\d*\s*(km|mi)/) - expect(stats_text).to match(/\d+\s*points/) - - # Check for map scale control - expect(page).to have_css('.leaflet-control-scale') - expect(page).to have_css('.leaflet-control-scale-line') - end - - it 'displays map attributions' do - # Check for attribution control - expect(page).to have_css('.leaflet-control-attribution') - - # Verify attribution text is present - attribution_text = find('.leaflet-control-attribution').text - expect(attribution_text).not_to be_empty - - # Common attribution text patterns - expect(attribution_text).to match(/©|©|OpenStreetMap|contributors/i) - end - end - - context 'polyline popup content' do - context 'with km distance unit' do - include_context 'authenticated map user' - - it 'displays route popup with correct data in kilometers' do - # Verify the user has km as distance unit (default) - expect(user.safe_settings.distance_unit).to eq('km') - - # Wait for polylines to load - expect(page).to have_css('.leaflet-overlay-pane', wait: 10) - sleep 2 # Allow polylines to fully render - - # Verify that polylines are present and interactive - expect(page).to have_css('[data-maps-target="container"]') - - # Check that the map has the correct user settings - map_element = find('#map') - user_settings = JSON.parse(map_element['data-user_settings']) - # The raw settings structure has distance_unit nested under maps - expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - - # Try to trigger polyline interaction and verify popup structure - popup_content = trigger_polyline_hover_and_get_popup - - if popup_content - # Verify popup contains all required fields - expect(verify_popup_content_structure(popup_content, 'km')).to be true - - # Extract and verify specific data - popup_data = extract_popup_data(popup_content) - - # Verify start and end times are present and formatted - expect(popup_data[:start]).to be_present - expect(popup_data[:end]).to be_present - - # Verify duration is present - expect(popup_data[:duration]).to be_present - - # Verify total distance includes km unit - expect(popup_data[:total_distance]).to include('km') - - # Verify current speed includes km/h unit - expect(popup_data[:current_speed]).to include('km/h') - else - # If we can't trigger the popup, at least verify the setup is correct - expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - puts 'Note: Polyline popup interaction could not be triggered in test environment' - end - end - end - - context 'with miles distance unit' do - let(:user_with_miles) do - create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') - end - - let!(:points_for_miles_user) do - # Create a series of points that form a route for the miles user - [ - create(:point, user: user_with_miles, - lonlat: 'POINT(13.404954 52.520008)', - timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), - create(:point, user: user_with_miles, - lonlat: 'POINT(13.405954 52.521008)', - timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), - create(:point, user: user_with_miles, - lonlat: 'POINT(13.406954 52.522008)', - timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), - create(:point, user: user_with_miles, - lonlat: 'POINT(13.407954 52.523008)', - timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) - ] - end - - before do - # Reset session and sign in with the miles user - Capybara.reset_sessions! - sign_in_and_visit_map(user_with_miles) - end - - it 'displays route popup with correct data in miles' do - # Verify the user has miles as distance unit - expect(user_with_miles.safe_settings.distance_unit).to eq('mi') - - # Wait for polylines to load - expect(page).to have_css('.leaflet-overlay-pane', wait: 10) - sleep 2 # Allow polylines to fully render - - # Verify that polylines are present and interactive - expect(page).to have_css('[data-maps-target="container"]') - - # Check that the map has the correct user settings - map_element = find('#map') - user_settings = JSON.parse(map_element['data-user_settings']) - expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - - # Try to trigger polyline interaction and verify popup structure - popup_content = trigger_polyline_hover_and_get_popup - - if popup_content - # Verify popup contains all required fields - expect(verify_popup_content_structure(popup_content, 'mi')).to be true - - # Extract and verify specific data - popup_data = extract_popup_data(popup_content) - - # Verify start and end times are present and formatted - expect(popup_data[:start]).to be_present - expect(popup_data[:end]).to be_present - - # Verify duration is present - expect(popup_data[:duration]).to be_present - - # Verify total distance includes miles unit - expect(popup_data[:total_distance]).to include('mi') - - # Verify current speed is in mph for miles unit - expect(popup_data[:current_speed]).to include('mph') - else - # If we can't trigger the popup, at least verify the setup is correct - expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - puts 'Note: Polyline popup interaction could not be triggered in test environment' - end - end - end - end - - context 'polyline popup content' do - context 'with km distance unit' do - let(:user_with_km) do - create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123') - end - - let!(:points_for_km_user) do - # Create a series of points that form a route for the km user - [ - create(:point, user: user_with_km, - lonlat: 'POINT(13.404954 52.520008)', - timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), - create(:point, user: user_with_km, - lonlat: 'POINT(13.405954 52.521008)', - timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), - create(:point, user: user_with_km, - lonlat: 'POINT(13.406954 52.522008)', - timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), - create(:point, user: user_with_km, - lonlat: 'POINT(13.407954 52.523008)', - timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) - ] - end - - before do - # Reset session and sign in with the km user - Capybara.reset_sessions! - sign_in_and_visit_map(user_with_km) - end - - it 'displays route popup with correct data in kilometers' do - # Verify the user has km as distance unit - expect(user_with_km.safe_settings.distance_unit).to eq('km') - - # Wait for polylines to load - expect(page).to have_css('.leaflet-overlay-pane', wait: 10) - sleep 2 # Allow polylines to fully render - - # Verify that polylines are present and interactive - expect(page).to have_css('[data-maps-target="container"]') - - # Check that the map has the correct user settings - map_element = find('#map') - user_settings = JSON.parse(map_element['data-user_settings']) - # The raw settings structure has distance_unit nested under maps - expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - - # Try to trigger polyline interaction and verify popup structure - popup_content = trigger_polyline_hover_and_get_popup - - if popup_content - # Verify popup contains all required fields - expect(verify_popup_content_structure(popup_content, 'km')).to be true - - # Extract and verify specific data - popup_data = extract_popup_data(popup_content) - - # Verify start and end times are present and formatted - expect(popup_data[:start]).to be_present - expect(popup_data[:end]).to be_present - - # Verify duration is present - expect(popup_data[:duration]).to be_present - - # Verify total distance includes km unit - expect(popup_data[:total_distance]).to include('km') - - # Verify current speed includes km/h unit - expect(popup_data[:current_speed]).to include('km/h') - else - # If we can't trigger the popup, at least verify the setup is correct - expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - puts 'Note: Polyline popup interaction could not be triggered in test environment' - end - end - end - - context 'with miles distance unit' do - let(:user_with_miles) do - create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') - end - - let!(:points_for_miles_user) do - # Create a series of points that form a route for the miles user - [ - create(:point, user: user_with_miles, - lonlat: 'POINT(13.404954 52.520008)', - timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), - create(:point, user: user_with_miles, - lonlat: 'POINT(13.405954 52.521008)', - timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), - create(:point, user: user_with_miles, - lonlat: 'POINT(13.406954 52.522008)', - timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), - create(:point, user: user_with_miles, - lonlat: 'POINT(13.407954 52.523008)', - timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) - ] - end - - before do - # Reset session and sign in with the miles user - Capybara.reset_sessions! - sign_in_and_visit_map(user_with_miles) - end - - it 'displays route popup with correct data in miles' do - # Verify the user has miles as distance unit - expect(user_with_miles.safe_settings.distance_unit).to eq('mi') - - # Wait for polylines to load - expect(page).to have_css('.leaflet-overlay-pane', wait: 10) - sleep 2 # Allow polylines to fully render - - # Verify that polylines are present and interactive - expect(page).to have_css('[data-maps-target="container"]') - - # Check that the map has the correct user settings - map_element = find('#map') - user_settings = JSON.parse(map_element['data-user_settings']) - expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - - # Try to trigger polyline interaction and verify popup structure - popup_content = trigger_polyline_hover_and_get_popup - - if popup_content - # Verify popup contains all required fields - expect(verify_popup_content_structure(popup_content, 'mi')).to be true - - # Extract and verify specific data - popup_data = extract_popup_data(popup_content) - - # Verify start and end times are present and formatted - expect(popup_data[:start]).to be_present - expect(popup_data[:end]).to be_present - - # Verify duration is present - expect(popup_data[:duration]).to be_present - - # Verify total distance includes miles unit - expect(popup_data[:total_distance]).to include('mi') - - # Verify current speed is in mph for miles unit - expect(popup_data[:current_speed]).to include('mph') - else - # If we can't trigger the popup, at least verify the setup is correct - expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - puts 'Note: Polyline popup interaction could not be triggered in test environment' - end - end - end - end - - xcontext 'settings panel functionality' do - include_context 'authenticated map user' - - it 'allows updating route opacity settings' do - # Open settings panel - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - expect(page).to have_css('.leaflet-settings-panel', visible: true) - - # Find and update route opacity - within('.leaflet-settings-panel') do - opacity_input = find('#route-opacity') - expect(opacity_input.value).to eq('60') # Default value - - # Change opacity to 80% - opacity_input.fill_in(with: '80') - - # Submit the form - click_button 'Update' - end - - # Wait for success flash message - expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) - end - - it 'allows updating fog of war settings' do - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - # Update fog of war radius - fog_radius = find('#fog_of_war_meters') - fog_radius.fill_in(with: '100') - - # Update fog threshold - fog_threshold = find('#fog_of_war_threshold') - fog_threshold.fill_in(with: '120') - - click_button 'Update' - end - - # Wait for success flash message - expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) - end - - it 'allows updating route splitting settings' do - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - # Update meters between routes - meters_input = find('#meters_between_routes') - meters_input.fill_in(with: '750') - - # Update minutes between routes - minutes_input = find('#minutes_between_routes') - minutes_input.fill_in(with: '45') - - click_button 'Update' - end - - # Wait for success flash message - expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) - end - - it 'allows toggling points rendering mode' do - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - # Check current mode (should be 'raw' by default) - expect(find('#raw')).to be_checked - - # Switch to simplified mode - choose('simplified') - - click_button 'Update' - end - - # Wait for success flash message - expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) - end - - it 'allows toggling live map functionality' do - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - live_map_checkbox = find('#live_map_enabled') - initial_state = live_map_checkbox.checked? - - # Toggle the checkbox - live_map_checkbox.click - - click_button 'Update' - end - - # Wait for success flash message - expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) - end - - it 'allows toggling speed-colored routes' do - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - speed_colored_checkbox = find('#speed_colored_routes') - initial_state = speed_colored_checkbox.checked? - - # Toggle speed-colored routes - speed_colored_checkbox.click - - click_button 'Update' - end - - # Wait for success flash message - expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) - end - - it 'allows updating speed color scale' do - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - # Update speed color scale - scale_input = find('#speed_color_scale') - new_scale = '0:#ff0000|25:#ffff00|50:#00ff00|100:#0000ff' - scale_input.fill_in(with: new_scale) - - click_button 'Update' - end - - # Wait for success flash message - expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) - end - - it 'opens and interacts with gradient editor modal' do - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - click_button 'Edit Scale' - end - - # Verify modal opens - expect(page).to have_css('#gradient-editor-modal', wait: 5) - - within('#gradient-editor-modal') do - expect(page).to have_content('Edit Speed Color Scale') - - # Test adding a new row - click_button 'Add Row' - - # Test canceling - click_button 'Cancel' - end - - # Verify modal closes - expect(page).not_to have_css('#gradient-editor-modal') - end - end - - context 'layer management' do - include_context 'authenticated map user' - include_examples 'expandable layer control' - - it 'manages base layer switching' do - # Expand layer control - expand_layer_control - - # Test switching between base layers - within('.leaflet-control-layers') do - # Should have OpenStreetMap selected by default - expect(page).to have_css('input[type="radio"]:checked') - - # Try to switch to another base layer if available - radio_buttons = all('input[type="radio"]') - if radio_buttons.length > 1 - # Click on a different base layer - radio_buttons.last.click - sleep 1 # Allow layer to load - end - end - - collapse_layer_control - end - - it 'manages overlay layer visibility' do - expand_layer_control - - within('.leaflet-control-layers') do - # Test toggling overlay layers - checkboxes = all('input[type="checkbox"]') - - checkboxes.each do |checkbox| - # Get the layer name from the label - layer_name = checkbox.find(:xpath, './following-sibling::span').text.strip - - # Toggle the layer - initial_state = checkbox.checked? - checkbox.click - sleep 0.5 - - # Verify the layer state changed - expect(checkbox.checked?).to eq(!initial_state) - end - end - - collapse_layer_control - end - - it 'preserves layer states after settings updates' do - # Enable some layers first - expand_layer_control - - # Remember initial layer states - layer_states = {} - within('.leaflet-control-layers') do - all('input[type="checkbox"]').each do |checkbox| - layer_name = checkbox.find(:xpath, './following-sibling::span').text.strip - layer_states[layer_name] = checkbox.checked? - - # Enable the layer if not already enabled - checkbox.click unless checkbox.checked? - end - end - - collapse_layer_control - - # Update a setting - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - - within('.leaflet-settings-panel') do - opacity_input = find('#route-opacity') - opacity_input.fill_in(with: '70') - click_button 'Update' - end - - expect(page).to have_content('Settings updated', wait: 10) - - # Verify layer control still works - expand_layer_control - expect(page).to have_css('.leaflet-control-layers-list') - collapse_layer_control - end - end - - context 'calendar panel functionality' do - include_context 'authenticated map user' - - it 'opens and displays calendar navigation' do - # Wait for the map controller to fully initialize and create the toggle button - expect(page).to have_css('#map', wait: 10) - expect(page).to have_css('.leaflet-container', wait: 10) - - # Additional wait for the controller to finish initializing all controls - sleep 2 - - # Click calendar button - calendar_button = find('.toggle-panel-button', wait: 15) - expect(calendar_button).to be_visible - - # Verify button is clickable - expect(calendar_button).not_to be_disabled - - # For now, just verify the button exists and is functional - # The calendar panel functionality may need JavaScript debugging - # that's beyond the scope of system tests - expect(calendar_button.text).to eq('📅') - end - - it 'allows year selection and month navigation' do - # This test is skipped due to calendar panel JavaScript interaction issues - # The calendar button exists but the panel doesn't open reliably in test environment - skip 'Calendar panel JavaScript interaction needs debugging' - end - - it 'displays visited cities information' do - # This test is skipped due to calendar panel JavaScript interaction issues - # The calendar button exists but the panel doesn't open reliably in test environment - skip 'Calendar panel JavaScript interaction needs debugging' - end - - xit 'persists panel state in localStorage' do - # Wait for the map controller to fully initialize and create the toggle button - # The button is created dynamically by the JavaScript controller - expect(page).to have_css('#map', wait: 10) - expect(page).to have_css('.leaflet-container', wait: 10) - - # Additional wait for the controller to finish initializing all controls - # The toggle-panel-button is created by the addTogglePanelButton() method - # which is called after the map and all other controls are set up - sleep 2 - - # Now try to find the calendar button - calendar_button = nil - begin - calendar_button = find('.toggle-panel-button', wait: 15) - rescue Capybara::ElementNotFound - # If button still not found, check if map controller loaded properly - map_element = find('#map') - controller_data = map_element['data-controller'] - - # Log debug info for troubleshooting - puts "Map controller data: #{controller_data}" - puts "Map element classes: #{map_element[:class]}" - - # Try one more time with extended wait - calendar_button = find('.toggle-panel-button', wait: 20) - end - - # Verify button exists and is functional - expect(calendar_button).to be_present - calendar_button.click - - # Wait for panel to appear - expect(page).to have_css('.leaflet-right-panel', visible: true, wait: 10) - - # Close panel - calendar_button.click - - # Wait for panel to disappear - expect(page).not_to have_css('.leaflet-right-panel', visible: true, wait: 10) - - # Refresh page (user should still be signed in due to session) - page.refresh - expect(page).to have_css('#map', wait: 10) - expect(page).to have_css('.leaflet-container', wait: 10) - - # Wait for controller to reinitialize after refresh - sleep 2 - - # Panel should remember its state (though this is hard to test reliably in system tests) - # At minimum, verify the panel can be toggled after refresh - calendar_button = find('.toggle-panel-button', wait: 15) - calendar_button.click - expect(page).to have_css('.leaflet-right-panel', wait: 10) - end - end - - context 'point management' do - include_context 'authenticated map user' - - xit 'displays point popups with delete functionality' do - # Wait for points to load - expect(page).to have_css('.leaflet-marker-pane', wait: 10) - - # Try to find and click on a point marker - if page.has_css?('.leaflet-marker-icon') - first('.leaflet-marker-icon').click - sleep 1 - - # Should show popup with point information - if page.has_css?('.leaflet-popup-content') - popup_content = find('.leaflet-popup-content') - - # Verify popup contains expected information - expect(popup_content).to have_content('Timestamp:') - expect(popup_content).to have_content('Latitude:') - expect(popup_content).to have_content('Longitude:') - expect(popup_content).to have_content('Speed:') - expect(popup_content).to have_content('Battery:') - - # Should have delete link - expect(popup_content).to have_css('a.delete-point') - end - end - end - - xit 'handles point deletion with confirmation' do - # This test would require mocking the confirmation dialog and API call - # For now, we'll just verify the delete link exists and has the right attributes - expect(page).to have_css('.leaflet-marker-pane', wait: 10) - - if page.has_css?('.leaflet-marker-icon') - first('.leaflet-marker-icon').click - sleep 1 - - if page.has_css?('.leaflet-popup-content') - popup_content = find('.leaflet-popup-content') - - if popup_content.has_css?('a.delete-point') - delete_link = popup_content.find('a.delete-point') - expect(delete_link['data-id']).to be_present - expect(delete_link.text).to eq('[Delete]') - end - end - end - end - end - - context 'map initialization and error handling' do - include_context 'authenticated map user' - - context 'with user having no points' do - let(:user_no_points) { create(:user, password: 'password123') } - - before do - # Clear any existing session and sign in the new user - Capybara.reset_sessions! - sign_in_and_visit_map(user_no_points) - end - - it 'handles empty markers array gracefully' do - # Map should still initialize - expect(page).to have_css('#map') - expect(page).to have_css('.leaflet-container') - - # Should have default center - expect(page).to have_css('.leaflet-map-pane') - end - end - - context 'with user having minimal settings' do - let(:user_minimal) { create(:user, settings: {}, password: 'password123') } - - before do - # Clear any existing session and sign in the new user - Capybara.reset_sessions! - sign_in_and_visit_map(user_minimal) - end - - it 'handles missing user settings gracefully' do - # Map should still work with defaults - expect(page).to have_css('#map') - expect(page).to have_css('.leaflet-container') - - # Settings panel should work - settings_button = find('.map-settings-button', wait: 10) - settings_button.click - expect(page).to have_css('.leaflet-settings-panel') - end - end - - it 'displays appropriate controls and attributions' do - # Verify essential map controls are present - expect(page).to have_css('.leaflet-control-zoom') - expect(page).to have_css('.leaflet-control-layers') - expect(page).to have_css('.leaflet-control-attribution') - expect(page).to have_css('.leaflet-control-scale') - expect(page).to have_css('.leaflet-control-stats') - - # Verify custom controls (these are created dynamically by JavaScript) - expect(page).to have_css('.map-settings-button', wait: 10) - expect(page).to have_css('.toggle-panel-button', wait: 15) - end - end - - context 'performance and memory management' do - include_context 'authenticated map user' - - it 'properly cleans up on page navigation' do - # Navigate away and back to test cleanup - visit '/stats' - expect(page).to have_current_path('/stats') - - # Navigate back to map - visit '/map' - expect(page).to have_css('#map') - expect(page).to have_css('.leaflet-container') - end - - xit 'handles large datasets without crashing' do - # This test verifies the map can handle the existing dataset - # without JavaScript errors or timeouts - expect(page).to have_css('.leaflet-overlay-pane', wait: 15) - expect(page).to have_css('.leaflet-marker-pane', wait: 15) - - # Try zooming and panning to test performance - zoom_in_button = find('.leaflet-control-zoom-in') - 3.times do - zoom_in_button.click - sleep 0.3 - end - - # Map should still be responsive - expect(page).to have_css('.leaflet-container') - end - end - end -end