diff --git a/.app_version b/.app_version
index 3f8003cd..7b52f5e5 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.34.2
+0.35.0
diff --git a/.env.template b/.env.template
deleted file mode 100644
index e69de29b..00000000
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 bd07d30e..f958dd1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+
+# [0.35.0]
+
+⚠️ 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.
+
+## 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
diff --git a/Gemfile b/Gemfile
index 1e37a548..e1817622 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
@@ -29,12 +29,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'
@@ -48,7 +48,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]
@@ -80,4 +79,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 e4ebfb9f..bcf42c11 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -107,10 +107,10 @@ GEM
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
- bigdecimal (3.2.3)
+ 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)
@@ -139,12 +139,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)
@@ -161,7 +161,7 @@ GEM
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
- erb (5.0.2)
+ erb (5.1.3)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -251,7 +251,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)
@@ -296,7 +296,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)
@@ -320,7 +320,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.2)
+ rack (3.2.3)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -362,10 +362,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)
@@ -407,17 +408,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)
@@ -447,10 +448,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)
@@ -487,7 +488,7 @@ 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
@@ -501,7 +502,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)
@@ -512,7 +513,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)
warden (1.2.9)
rack (>= 2.0.9)
@@ -539,7 +540,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)
@@ -574,12 +575,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 1c86f540..44877af3 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-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{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-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-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-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)}.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-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}.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))}.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}}
\ No newline at end of file
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/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb
index 3412bb08..f01254a8 100644
--- a/app/controllers/users/registrations_controller.rb
+++ b/app/controllers/users/registrations_controller.rb
@@ -51,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
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..8bc4c29f 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);
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
+
+
-
+
`;
}
@@ -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
-
+
@@ -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/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/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/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/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
index 257aba87..5536a889 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 707d9cee..4a32be9e 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. Create your account
+ - 2. Configure your mobile app
+ - 3. Start tracking your location data securely
+ - 4. ...
+ - 5. You're beautiful!
+
+
<%= 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 b471a5cf..633337c1 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/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 @@
- <%= current_user.email %>
- <%= icon 'user' %>
+ <%= icon 'user' %>
<% if onboarding_modal_showable?(current_user) %>
<% end %>
diff --git a/config/cable.yml b/config/cable.yml
index e5713aea..08d639f8 100644
--- a/config/cable.yml
+++ b/config/cable.yml
@@ -1,6 +1,6 @@
default: &default
adapter: redis
- url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
+ url: <%= "#{ENV.fetch("REDIS_URL", "redis://localhost:6379")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
development:
<<: *default
diff --git a/config/environments/development.rb b/config/environments/development.rb
index c940de0e..4b5e99bf 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -86,7 +86,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 +99,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..146ec651 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'
@@ -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/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/strong_migrations.rb b/config/initializers/strong_migrations.rb
index ec978211..dfc903e2 100644
--- a/config/initializers/strong_migrations.rb
+++ b/config/initializers/strong_migrations.rb
@@ -1,10 +1,15 @@
+# frozen_string_literal: true
+
+return unless Rails.env.development?
+
# Mark existing migrations as safe
StrongMigrations.start_after = 20_250_122_150_500
# 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
+# 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
# Analyze tables after indexes are added
# Outdated statistics can sometimes hurt performance
diff --git a/config/routes.rb b/config/routes.rb
index d34aa775..38666530 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -124,7 +124,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/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/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 88%
rename from docker/Dockerfile.prod
rename to docker/Dockerfile
index 29279c81..16596609 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,10 +26,14 @@ RUN apt-get update -qq \
less \
libjemalloc2 libjemalloc-dev \
cmake \
- && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
+ ca-certificates \
+ && mkdir -p $APP_PATH \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install Node.js LTS for production/staging
+RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g yarn \
- && mkdir -p $APP_PATH \
&& rm -rf /var/lib/apt/lists/*
# Use jemalloc with check for architecture
@@ -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..bc9d0e94 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,6 +17,7 @@ services:
retries: 5
start_period: 30s
timeout: 10s
+
dawarich_db:
image: postgis/postgis:17-3.5-alpine
shm_size: 1G
@@ -27,17 +29,18 @@ services:
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:-}
+ 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:-}
+ 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/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/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 efeac67c..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
@@ -326,6 +326,70 @@ RSpec.describe 'Users::Registrations', type: :request do
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
{
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