diff --git a/.app_version b/.app_version
index 26bea73e..9eb2aa3f 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.31.0
+0.32.0
diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml
index 46244061..030fe9b4 100644
--- a/.github/workflows/build_and_push.yml
+++ b/.github/workflows/build_and_push.yml
@@ -71,9 +71,21 @@ jobs:
TAGS="freikin/dawarich:${VERSION}"
- # Set platforms based on release type
+ # Set platforms based on version type and release type
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6"
+ # Check if this is a patch version (x.y.z where z > 0)
+ if [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[1-9][0-9]*$ ]]; then
+ echo "Detected patch version ($VERSION) - building for AMD64 only"
+ PLATFORMS="linux/amd64"
+ elif [[ $VERSION =~ ^[0-9]+\.[0-9]+\.0$ ]]; then
+ echo "Detected minor version ($VERSION) - building for all platforms"
+ PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6"
+ else
+ echo "Version format not recognized or non-semver - using AMD64 only for safety"
+ PLATFORMS="linux/amd64"
+ fi
+
# Add :rc tag for pre-releases
if [ "${{ github.event.release.prerelease }}" = "true" ]; then
TAGS="${TAGS},freikin/dawarich:rc"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37fdb788..e0cb67ed 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@ 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.32.0] - 2025-09-13
+
+## Fixed
+
+- Tracked distance on year card on the Stats page will always be equal to the sum of distances on the monthly chart below it. #466
+- Stats are now being calculated for trial users as well as active ones.
+
+## Added
+
+- A cron job to generate daily tracks for users with new points since their last track generation. Being run every 4 hours.
+- A new month stat page, featuring insights on how user's month went: distance traveled, active days, countries visited and more.
+- Month stat page can now be shared via public link. User can limit access to the page by sharing period: 1/12/24 hours or permanent.
+
+## Changed
+
+- Stats page now loads significantly faster due to caching.
+- Data on the Stats page is being updated daily, except for total distance and number of geopoints tracked, which are being updated on the fly. Also, charts with yearly and monthly stats are being updated every hour.
+- Minor versions are now being built only for amd64 architecture to speed up the build process.
+- If user is not authorized to see a page, they will be redirected to the home page with appropriate message instead of seeing an error.
+
# [0.31.0] - 2025-09-04
The Search release
diff --git a/Gemfile b/Gemfile
index c7145245..f876777c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -7,9 +7,9 @@ ruby File.read('.ruby-version').strip
gem 'activerecord-postgis-adapter'
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
-gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'aws-sdk-core', '~> 3.215.1', require: false
gem 'aws-sdk-kms', '~> 1.96.0', require: false
+gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'bootsnap', require: false
gem 'chartkick'
gem 'data_migrate'
@@ -19,37 +19,38 @@ gem 'gpx'
gem 'groupdate'
gem 'httparty'
gem 'importmap-rails'
+gem 'jwt', '~> 2.8'
gem 'kaminari'
gem 'lograge'
gem 'oj'
gem 'parallel'
gem 'pg'
gem 'prometheus_exporter'
-gem 'rqrcode', '~> 3.0'
gem 'puma'
gem 'pundit'
gem 'rails', '~> 8.0'
+gem 'rails_icons'
gem 'redis'
gem 'rexml'
gem 'rgeo'
gem 'rgeo-activerecord'
gem 'rgeo-geojson'
+gem 'rqrcode', '~> 3.0'
gem 'rswag-api'
gem 'rswag-ui'
gem 'rubyzip', '~> 2.4'
-gem 'sentry-ruby'
gem 'sentry-rails'
-gem 'stackprof'
+gem 'sentry-ruby'
gem 'sidekiq'
gem 'sidekiq-cron'
gem 'sidekiq-limit_fetch'
gem 'sprockets-rails'
+gem 'stackprof'
gem 'stimulus-rails'
gem 'strong_migrations'
gem 'tailwindcss-rails'
gem 'turbo-rails'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
-gem 'jwt'
group :development, :test do
gem 'brakeman', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 74af4a35..882a41ad 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -130,7 +130,7 @@ GEM
chunky_png (1.4.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
- connection_pool (2.5.3)
+ connection_pool (2.5.4)
crack (1.0.0)
bigdecimal
rexml
@@ -172,7 +172,8 @@ GEM
railties (>= 6.1.0)
fakeredis (0.1.4)
ffaker (2.24.0)
- foreman (0.88.1)
+ foreman (0.90.0)
+ thor (~> 1.4)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
@@ -191,7 +192,7 @@ GEM
multi_xml (>= 0.5.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
- importmap-rails (2.1.0)
+ importmap-rails (2.2.2)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
@@ -304,7 +305,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.1.16)
+ rack (3.2.0)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -333,6 +334,9 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+ rails_icons (1.4.0)
+ nokogiri (~> 1.16, >= 1.16.4)
+ rails (> 6.1)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
@@ -421,11 +425,11 @@ GEM
ruby-progressbar (1.13.0)
rubyzip (2.4.1)
securerandom (0.4.1)
- selenium-webdriver (4.33.0)
+ selenium-webdriver (4.35.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
- rubyzip (>= 1.2.2, < 3.0)
+ rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (5.26.0)
railties (>= 5.0)
@@ -541,7 +545,7 @@ DEPENDENCIES
groupdate
httparty
importmap-rails
- jwt
+ jwt (~> 2.8)
kaminari
lograge
oj
@@ -553,6 +557,7 @@ DEPENDENCIES
puma
pundit
rails (~> 8.0)
+ rails_icons
redis
rexml
rgeo
diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css
index 2b37c492..56020d98 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.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.\!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;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.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.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}.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;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero-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;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.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(
--timeline-col-end,minmax(0,1fr)
);grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var(
--timeline-row-end,minmax(0,1fr)
- );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{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-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.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-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-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;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-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::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-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: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 .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.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: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::-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:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.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-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-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-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%}.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\/12{width:83.333333%}.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-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.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 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}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.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-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.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-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-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-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.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-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-opacity-30{--tw-border-opacity:0.3}.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-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/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-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.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-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}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-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\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.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-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-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-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-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.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}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.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)}.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-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.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}}.placeholder\:text-base-content\/50::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.placeholder\:text-base-content\/50::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\: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-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-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/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\: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\: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)}.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\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\: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\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}}
\ 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-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))}.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;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-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::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-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: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;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.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}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.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-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.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))}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.left-2{left:.5rem}.right-0{right:0}.right-2{right:.5rem}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.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-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-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-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%}.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\/12{width:83.333333%}.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-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}.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 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}.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-col-reverse{flex-direction:column-reverse}.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}.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-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-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-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-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-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.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-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/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-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.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-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-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-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-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.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-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\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.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-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-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-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-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.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-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.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-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.blur{--tw-blur:blur(8px)}.blur,.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-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.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}}.placeholder\:text-base-content\/50::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.placeholder\:text-base-content\/50::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.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\/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)}.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\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\: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\:w-3\/12{width:25%}.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\: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\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}
diff --git a/app/assets/images/backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg b/app/assets/images/backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg
new file mode 100644
index 00000000..1af6ba45
Binary files /dev/null and b/app/assets/images/backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg b/app/assets/images/backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg
new file mode 100644
index 00000000..f77795a8
Binary files /dev/null and b/app/assets/images/backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg b/app/assets/images/backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg
new file mode 100644
index 00000000..6ca91bf1
Binary files /dev/null and b/app/assets/images/backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg b/app/assets/images/backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg
new file mode 100644
index 00000000..3da2dd7b
Binary files /dev/null and b/app/assets/images/backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg b/app/assets/images/backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg
new file mode 100644
index 00000000..341af9dc
Binary files /dev/null and b/app/assets/images/backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg b/app/assets/images/backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg
new file mode 100644
index 00000000..f6d07ed9
Binary files /dev/null and b/app/assets/images/backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg b/app/assets/images/backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg
new file mode 100644
index 00000000..21304dcb
Binary files /dev/null and b/app/assets/images/backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg b/app/assets/images/backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg
new file mode 100644
index 00000000..41bde191
Binary files /dev/null and b/app/assets/images/backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg b/app/assets/images/backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg
new file mode 100644
index 00000000..bb2d8264
Binary files /dev/null and b/app/assets/images/backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg b/app/assets/images/backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg
new file mode 100644
index 00000000..01c5dd0d
Binary files /dev/null and b/app/assets/images/backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg b/app/assets/images/backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg
new file mode 100644
index 00000000..83176a59
Binary files /dev/null and b/app/assets/images/backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg b/app/assets/images/backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg
new file mode 100644
index 00000000..3ca271d8
Binary files /dev/null and b/app/assets/images/backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg differ
diff --git a/app/assets/images/backgrounds/months/pascal-debrunner-dppGOhcPBW0-unsplash.jpg b/app/assets/images/backgrounds/months/pascal-debrunner-dppGOhcPBW0-unsplash.jpg
new file mode 100644
index 00000000..693a7cb9
Binary files /dev/null and b/app/assets/images/backgrounds/months/pascal-debrunner-dppGOhcPBW0-unsplash.jpg differ
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index bd822bce..5e954a44 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -92,7 +92,7 @@
}
.loading-spinner::before {
- content: '🔵';
+ content: '';
font-size: 18px;
animation: spinner 1s linear infinite;
}
diff --git a/app/assets/svg/icons/lucide/outline/activity.svg b/app/assets/svg/icons/lucide/outline/activity.svg
new file mode 100644
index 00000000..629b81c9
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/activity.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/bell.svg b/app/assets/svg/icons/lucide/outline/bell.svg
new file mode 100644
index 00000000..c3a5ae9a
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/bell.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/svg/icons/lucide/outline/building.svg b/app/assets/svg/icons/lucide/outline/building.svg
new file mode 100644
index 00000000..d1a3f59b
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/building.svg
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/bus.svg b/app/assets/svg/icons/lucide/outline/bus.svg
new file mode 100644
index 00000000..9fdef2d1
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/bus.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/calendar-check-2.svg b/app/assets/svg/icons/lucide/outline/calendar-check-2.svg
new file mode 100644
index 00000000..c23c6d78
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/calendar-check-2.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/camera.svg b/app/assets/svg/icons/lucide/outline/camera.svg
new file mode 100644
index 00000000..1e82bf45
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/camera.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/car.svg b/app/assets/svg/icons/lucide/outline/car.svg
new file mode 100644
index 00000000..c99feda0
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/car.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/copy.svg b/app/assets/svg/icons/lucide/outline/copy.svg
new file mode 100644
index 00000000..f62ce99c
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/copy.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/earth.svg b/app/assets/svg/icons/lucide/outline/earth.svg
new file mode 100644
index 00000000..b682a309
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/earth.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/flame.svg b/app/assets/svg/icons/lucide/outline/flame.svg
new file mode 100644
index 00000000..0abb88a4
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/flame.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/flower.svg b/app/assets/svg/icons/lucide/outline/flower.svg
new file mode 100644
index 00000000..81265ee8
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/flower.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/svg/icons/lucide/outline/globe.svg b/app/assets/svg/icons/lucide/outline/globe.svg
new file mode 100644
index 00000000..53c80ee3
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/globe.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/house.svg b/app/assets/svg/icons/lucide/outline/house.svg
new file mode 100644
index 00000000..10e4097e
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/house.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/info.svg b/app/assets/svg/icons/lucide/outline/info.svg
new file mode 100644
index 00000000..2a46eac2
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/info.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/leaf.svg b/app/assets/svg/icons/lucide/outline/leaf.svg
new file mode 100644
index 00000000..af8901e4
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/leaf.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/svg/icons/lucide/outline/lightbulb.svg b/app/assets/svg/icons/lucide/outline/lightbulb.svg
new file mode 100644
index 00000000..3f4e4091
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/lightbulb.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/link.svg b/app/assets/svg/icons/lucide/outline/link.svg
new file mode 100644
index 00000000..645e746a
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/link.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/map-pin-plus.svg b/app/assets/svg/icons/lucide/outline/map-pin-plus.svg
new file mode 100644
index 00000000..794d53cf
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/map-pin-plus.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/map-pin.svg b/app/assets/svg/icons/lucide/outline/map-pin.svg
new file mode 100644
index 00000000..5d73fbd4
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/map-pin.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/map-plus.svg b/app/assets/svg/icons/lucide/outline/map-plus.svg
new file mode 100644
index 00000000..c326bb9d
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/map-plus.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/map.svg b/app/assets/svg/icons/lucide/outline/map.svg
new file mode 100644
index 00000000..8a852560
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/map.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/plane.svg b/app/assets/svg/icons/lucide/outline/plane.svg
new file mode 100644
index 00000000..6f843bf8
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/plane.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/refresh-ccw.svg b/app/assets/svg/icons/lucide/outline/refresh-ccw.svg
new file mode 100644
index 00000000..9088690e
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/refresh-ccw.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/svg/icons/lucide/outline/share.svg b/app/assets/svg/icons/lucide/outline/share.svg
new file mode 100644
index 00000000..9165e1ce
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/share.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/shopping-cart.svg b/app/assets/svg/icons/lucide/outline/shopping-cart.svg
new file mode 100644
index 00000000..6d9f8844
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/shopping-cart.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/snowflake.svg b/app/assets/svg/icons/lucide/outline/snowflake.svg
new file mode 100644
index 00000000..2d3f15f4
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/snowflake.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/star.svg b/app/assets/svg/icons/lucide/outline/star.svg
new file mode 100644
index 00000000..b35bedd2
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/star.svg
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/tree-palm.svg b/app/assets/svg/icons/lucide/outline/tree-palm.svg
new file mode 100644
index 00000000..685a5ea7
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/tree-palm.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/svg/icons/lucide/outline/trending-up.svg b/app/assets/svg/icons/lucide/outline/trending-up.svg
new file mode 100644
index 00000000..2ff1fb20
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/trending-up.svg
@@ -0,0 +1,14 @@
+
+
+
+
diff --git a/app/assets/svg/icons/lucide/outline/trophy.svg b/app/assets/svg/icons/lucide/outline/trophy.svg
new file mode 100644
index 00000000..f254d0c3
--- /dev/null
+++ b/app/assets/svg/icons/lucide/outline/trophy.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/app/controllers/api/v1/areas_controller.rb b/app/controllers/api/v1/areas_controller.rb
index 4ccebd7c..81e20d17 100644
--- a/app/controllers/api/v1/areas_controller.rb
+++ b/app/controllers/api/v1/areas_controller.rb
@@ -15,7 +15,7 @@ class Api::V1::AreasController < ApiController
if @area.save
render json: @area, status: :created
else
- render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
+ render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
end
end
@@ -23,7 +23,7 @@ class Api::V1::AreasController < ApiController
if @area.update(area_params)
render json: @area, status: :ok
else
- render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
+ render json: { errors: @area.errors.full_messages }, status: :unprocessable_content
end
end
diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb
new file mode 100644
index 00000000..5743b0e7
--- /dev/null
+++ b/app/controllers/api/v1/maps/hexagons_controller.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+class Api::V1::Maps::HexagonsController < ApiController
+ skip_before_action :authenticate_api_key, if: :public_sharing_request?
+ before_action :validate_bbox_params, except: [:bounds]
+ before_action :set_user_and_dates
+
+ def index
+ service = Maps::HexagonGrid.new(hexagon_params)
+ result = service.call
+
+ Rails.logger.debug "Hexagon service result: #{result['features']&.count || 0} features"
+ render json: result
+ rescue Maps::HexagonGrid::BoundingBoxTooLargeError,
+ Maps::HexagonGrid::InvalidCoordinatesError => e
+ render json: { error: e.message }, status: :bad_request
+ rescue Maps::HexagonGrid::PostGISError => e
+ render json: { error: e.message }, status: :internal_server_error
+ rescue StandardError => _e
+ handle_service_error
+ end
+
+ def bounds
+ # Get the bounding box of user's points for the date range
+ return render json: { error: 'No user found' }, status: :not_found unless @target_user
+ return render json: { error: 'No date range specified' }, status: :bad_request unless @start_date && @end_date
+
+ # Convert dates to timestamps (handle both string and timestamp formats)
+ start_timestamp = case @start_date
+ when String
+ # Check if it's a numeric string (timestamp) or date string
+ if @start_date.match?(/^\d+$/)
+ @start_date.to_i
+ else
+ Time.parse(@start_date).to_i
+ end
+ when Integer
+ @start_date
+ else
+ @start_date.to_i
+ end
+ end_timestamp = case @end_date
+ when String
+ # Check if it's a numeric string (timestamp) or date string
+ if @end_date.match?(/^\d+$/)
+ @end_date.to_i
+ else
+ Time.parse(@end_date).to_i
+ end
+ when Integer
+ @end_date
+ else
+ @end_date.to_i
+ end
+
+ points_relation = @target_user.points.where(timestamp: start_timestamp..end_timestamp)
+ point_count = points_relation.count
+
+ if point_count.positive?
+ bounds_result = ActiveRecord::Base.connection.exec_query(
+ "SELECT MIN(latitude) as min_lat, MAX(latitude) as max_lat,
+ MIN(longitude) as min_lng, MAX(longitude) as max_lng
+ FROM points
+ WHERE user_id = $1
+ AND timestamp BETWEEN $2 AND $3",
+ 'bounds_query',
+ [@target_user.id, start_timestamp, end_timestamp]
+ ).first
+
+ render json: {
+ min_lat: bounds_result['min_lat'].to_f,
+ max_lat: bounds_result['max_lat'].to_f,
+ min_lng: bounds_result['min_lng'].to_f,
+ max_lng: bounds_result['max_lng'].to_f,
+ point_count: point_count
+ }
+ else
+ render json: {
+ error: 'No data found for the specified date range',
+ point_count: 0
+ }, status: :not_found
+ end
+ end
+
+ private
+
+ def bbox_params
+ params.permit(:min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :viewport_width, :viewport_height)
+ end
+
+ def hexagon_params
+ bbox_params.merge(
+ user_id: @target_user&.id,
+ start_date: @start_date,
+ end_date: @end_date
+ )
+ end
+
+ def set_user_and_dates
+ return set_public_sharing_context if params[:uuid].present?
+
+ set_authenticated_context
+ end
+
+ def set_public_sharing_context
+ @stat = Stat.find_by(sharing_uuid: params[:uuid])
+
+ unless @stat&.public_accessible?
+ render json: {
+ error: 'Shared stats not found or no longer available'
+ }, status: :not_found and return
+ end
+
+ @target_user = @stat.user
+ @start_date = Date.new(@stat.year, @stat.month, 1).beginning_of_day.iso8601
+ @end_date = Date.new(@stat.year, @stat.month, 1).end_of_month.end_of_day.iso8601
+ end
+
+ def set_authenticated_context
+ @target_user = current_api_user
+ @start_date = params[:start_date]
+ @end_date = params[:end_date]
+ end
+
+ def handle_service_error
+ render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error
+ end
+
+ def public_sharing_request?
+ params[:uuid].present?
+ end
+
+ def validate_bbox_params
+ required_params = %w[min_lon min_lat max_lon max_lat]
+ missing_params = required_params.select { |param| params[param].blank? }
+
+ return unless missing_params.any?
+
+ render json: {
+ error: "Missing required parameters: #{missing_params.join(', ')}"
+ }, status: :bad_request
+ end
+end
diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb
index 10620730..7404ec01 100644
--- a/app/controllers/api/v1/settings_controller.rb
+++ b/app/controllers/api/v1/settings_controller.rb
@@ -18,7 +18,7 @@ class Api::V1::SettingsController < ApiController
status: :ok
else
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
- status: :unprocessable_entity
+ status: :unprocessable_content
end
end
diff --git a/app/controllers/api/v1/subscriptions_controller.rb b/app/controllers/api/v1/subscriptions_controller.rb
index 2da2e97d..cc510d67 100644
--- a/app/controllers/api/v1/subscriptions_controller.rb
+++ b/app/controllers/api/v1/subscriptions_controller.rb
@@ -15,6 +15,6 @@ class Api::V1::SubscriptionsController < ApiController
render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized
rescue ArgumentError => e
ExceptionReporter.call(e)
- render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity
+ render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_content
end
end
diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb
index 248e5ea7..4ec4173b 100644
--- a/app/controllers/api/v1/visits_controller.rb
+++ b/app/controllers/api/v1/visits_controller.rb
@@ -19,7 +19,7 @@ class Api::V1::VisitsController < ApiController
render json: Api::VisitSerializer.new(service.visit).call
else
error_message = service.errors || 'Failed to create visit'
- render json: { error: error_message }, status: :unprocessable_entity
+ render json: { error: error_message }, status: :unprocessable_content
end
end
@@ -34,7 +34,7 @@ class Api::V1::VisitsController < ApiController
# Validate that we have at least 2 visit IDs
visit_ids = params[:visit_ids]
if visit_ids.blank? || visit_ids.length < 2
- return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_entity
+ return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_content
end
# Find all visits that belong to the current user
@@ -52,7 +52,7 @@ class Api::V1::VisitsController < ApiController
if merged_visit&.persisted?
render json: Api::VisitSerializer.new(merged_visit).call, status: :ok
else
- render json: { error: service.errors.join(', ') }, status: :unprocessable_entity
+ render json: { error: service.errors.join(', ') }, status: :unprocessable_content
end
end
@@ -71,7 +71,7 @@ class Api::V1::VisitsController < ApiController
updated_count: result[:count]
}, status: :ok
else
- render json: { error: service.errors.join(', ') }, status: :unprocessable_entity
+ render json: { error: service.errors.join(', ') }, status: :unprocessable_content
end
end
@@ -84,7 +84,7 @@ class Api::V1::VisitsController < ApiController
render json: {
error: 'Failed to delete visit',
errors: visit.errors.full_messages
- }, status: :unprocessable_entity
+ }, status: :unprocessable_content
end
rescue ActiveRecord::RecordNotFound
render json: { error: 'Visit not found' }, status: :not_found
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 500b9711..29062343 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -3,6 +3,8 @@
class ApplicationController < ActionController::Base
include Pundit::Authorization
+ rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
+
before_action :unread_notifications, :set_self_hosted_status
protected
@@ -16,13 +18,13 @@ class ApplicationController < ActionController::Base
def authenticate_admin!
return if current_user&.admin?
- redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
+ user_not_authorized
end
def authenticate_self_hosted!
return if DawarichSettings.self_hosted?
- redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
+ user_not_authorized
end
def authenticate_active_user!
@@ -34,7 +36,7 @@ class ApplicationController < ActionController::Base
def authenticate_non_self_hosted!
return unless DawarichSettings.self_hosted?
- redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
+ user_not_authorized
end
private
@@ -42,4 +44,10 @@ class ApplicationController < ActionController::Base
def set_self_hosted_status
@self_hosted = DawarichSettings.self_hosted?
end
+
+ def user_not_authorized
+ redirect_back fallback_location: root_path,
+ alert: 'You are not authorized to perform this action.',
+ status: :see_other
+ end
end
diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb
index efd2d502..0c59e1bf 100644
--- a/app/controllers/exports_controller.rb
+++ b/app/controllers/exports_controller.rb
@@ -27,7 +27,7 @@ class ExportsController < ApplicationController
ExceptionReporter.call(e)
- redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity
+ redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_content
end
def destroy
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
index 3ee75a95..96049978 100644
--- a/app/controllers/imports_controller.rb
+++ b/app/controllers/imports_controller.rb
@@ -13,9 +13,9 @@ class ImportsController < ApplicationController
def index
@imports = policy_scope(Import)
- .select(:id, :name, :source, :created_at, :processed, :status)
- .order(created_at: :desc)
- .page(params[:page])
+ .select(:id, :name, :source, :created_at, :processed, :status)
+ .order(created_at: :desc)
+ .page(params[:page])
end
def show; end
@@ -43,7 +43,7 @@ class ImportsController < ApplicationController
raw_files = Array(files_params).reject(&:blank?)
if raw_files.empty?
- redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity and return
+ redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_content and return
end
created_imports = []
@@ -62,7 +62,7 @@ class ImportsController < ApplicationController
else
redirect_to new_import_path,
alert: 'No valid file references were found. Please upload files using the file selector.',
- status: :unprocessable_entity and return
+ status: :unprocessable_content and return
end
rescue StandardError => e
if created_imports.present?
@@ -74,7 +74,7 @@ class ImportsController < ApplicationController
Rails.logger.error e.backtrace.join("\n")
ExceptionReporter.call(e)
- redirect_to new_import_path, alert: e.message, status: :unprocessable_entity
+ redirect_to new_import_path, alert: e.message, status: :unprocessable_content
end
def destroy
@@ -117,7 +117,7 @@ class ImportsController < ApplicationController
# Extract filename and extension
basename = File.basename(original_name, File.extname(original_name))
extension = File.extname(original_name)
-
+
# Add current datetime
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
"#{basename}_#{timestamp}#{extension}"
@@ -126,6 +126,6 @@ class ImportsController < ApplicationController
def validate_points_limit
limit_exceeded = PointsLimitExceeded.new(current_user).call
- redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_entity if limit_exceeded
+ redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_content if limit_exceeded
end
end
diff --git a/app/controllers/settings/users_controller.rb b/app/controllers/settings/users_controller.rb
index f00a28ce..e89f735c 100644
--- a/app/controllers/settings/users_controller.rb
+++ b/app/controllers/settings/users_controller.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
class Settings::UsersController < ApplicationController
- before_action :authenticate_self_hosted!, except: [:export, :import]
- before_action :authenticate_admin!, except: [:export, :import]
+ before_action :authenticate_self_hosted!, except: %i[export import]
before_action :authenticate_user!
+ before_action :authenticate_admin!, except: %i[export import]
def index
@users = User.order(created_at: :desc)
@@ -19,7 +19,7 @@ class Settings::UsersController < ApplicationController
if @user.update(user_params)
redirect_to settings_users_url, notice: 'User was successfully updated.'
else
- redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_entity
+ redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_content
end
end
@@ -33,7 +33,7 @@ class Settings::UsersController < ApplicationController
if @user.save
redirect_to settings_users_url, notice: 'User was successfully created'
else
- redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_entity
+ redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_content
end
end
@@ -43,7 +43,7 @@ class Settings::UsersController < ApplicationController
if @user.destroy
redirect_to settings_url, notice: 'User was successfully deleted.'
else
- redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_entity
+ redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_content
end
end
@@ -90,8 +90,7 @@ class Settings::UsersController < ApplicationController
end
def validate_archive_file(archive_file)
- unless archive_file.content_type == 'application/zip' ||
- archive_file.content_type == 'application/x-zip-compressed' ||
+ unless ['application/zip', 'application/x-zip-compressed'].include?(archive_file.content_type) ||
File.extname(archive_file.original_filename).downcase == '.zip'
redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.' and return
diff --git a/app/controllers/shared/stats_controller.rb b/app/controllers/shared/stats_controller.rb
new file mode 100644
index 00000000..e660dbcf
--- /dev/null
+++ b/app/controllers/shared/stats_controller.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class Shared::StatsController < ApplicationController
+ before_action :authenticate_user!, except: [:show]
+ before_action :authenticate_active_user!, only: [:update]
+
+ def show
+ @stat = Stat.find_by(sharing_uuid: params[:uuid])
+
+ unless @stat&.public_accessible?
+ return redirect_to root_path,
+ alert: 'Shared stats not found or no longer available'
+ end
+
+ @year = @stat.year
+ @month = @stat.month
+ @user = @stat.user
+ @is_public_view = true
+ @data_bounds = @stat.calculate_data_bounds
+
+ render 'stats/public_month'
+ end
+
+ def update
+ @year = params[:year].to_i
+ @month = params[:month].to_i
+ @stat = current_user.stats.find_by(year: @year, month: @month)
+
+ return head :not_found unless @stat
+
+ if params[:enabled] == '1'
+ @stat.enable_sharing!(expiration: params[:expiration] || 'permanent')
+ sharing_url = shared_stat_url(@stat.sharing_uuid)
+
+ render json: {
+ success: true,
+ sharing_url: sharing_url,
+ message: 'Sharing enabled successfully'
+ }
+ else
+ @stat.disable_sharing!
+
+ render json: {
+ success: true,
+ message: 'Sharing disabled successfully'
+ }
+ end
+ rescue StandardError
+ render json: {
+ success: false,
+ message: 'Failed to update sharing settings'
+ }, status: :unprocessable_content
+ end
+end
diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb
index 710f9b60..8d735acf 100644
--- a/app/controllers/stats_controller.rb
+++ b/app/controllers/stats_controller.rb
@@ -16,6 +16,14 @@ class StatsController < ApplicationController
@year_distances = { @year => Stat.year_distance(@year, current_user) }
end
+ def month
+ @year = params[:year].to_i
+ @month = params[:month].to_i
+ @stat = current_user.stats.find_by(year: @year, month: @month)
+ @previous_stat = current_user.stats.find_by(year: @year, month: @month - 1) if @month > 1
+ @average_distance_this_year = current_user.stats.where(year: @year).average(:distance).to_i / 1000
+ end
+
def update
if params[:month] == 'all'
(1..12).each do |month|
diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb
index 1880002b..00764a96 100644
--- a/app/controllers/trips_controller.rb
+++ b/app/controllers/trips_controller.rb
@@ -16,9 +16,9 @@ class TripsController < ApplicationController
end
@photo_sources = @trip.photo_sources
- if @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank?
- Trips::CalculateAllJob.perform_later(@trip.id, current_user.safe_settings.distance_unit)
- end
+ return unless @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank?
+
+ Trips::CalculateAllJob.perform_later(@trip.id, current_user.safe_settings.distance_unit)
end
def new
@@ -34,7 +34,7 @@ class TripsController < ApplicationController
if @trip.save
redirect_to @trip, notice: 'Trip was successfully created. Data is being calculated in the background.'
else
- render :new, status: :unprocessable_entity
+ render :new, status: :unprocessable_content
end
end
@@ -42,7 +42,7 @@ class TripsController < ApplicationController
if @trip.update(trip_params)
redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other
else
- render :edit, status: :unprocessable_entity
+ render :edit, status: :unprocessable_content
end
end
diff --git a/app/controllers/visits_controller.rb b/app/controllers/visits_controller.rb
index a22e60e5..bc8c1d8c 100644
--- a/app/controllers/visits_controller.rb
+++ b/app/controllers/visits_controller.rb
@@ -22,7 +22,7 @@ class VisitsController < ApplicationController
if @visit.update(visit_params)
redirect_back(fallback_location: visits_path(status: :suggested))
else
- render :edit, status: :unprocessable_entity
+ render :edit, status: :unprocessable_content
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2fb02162..5b453fbc 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -17,80 +17,10 @@ module ApplicationHelper
{ start_at:, end_at: }
end
- def timespan(month, year)
- month = DateTime.new(year, month)
- start_at = month.beginning_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
- end_at = month.end_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
-
- { start_at:, end_at: }
- end
-
def header_colors
%w[info success warning error accent secondary primary]
end
- def countries_and_cities_stat_for_year(year, stats)
- data = { countries: [], cities: [] }
-
- stats.select { _1.year == year }.each do
- data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
- data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
- end
-
- data[:cities].flatten!.uniq!
- data[:countries].flatten!.uniq!
-
- grouped_by_country = {}
- stats.select { _1.year == year }.each do |stat|
- stat.toponyms.flatten.each do |toponym|
- country = toponym['country']
- next unless country.present?
-
- grouped_by_country[country] ||= []
-
- next unless toponym['cities'].present?
-
- toponym['cities'].each do |city_data|
- city = city_data['city']
- grouped_by_country[country] << city if city.present?
- end
- end
- end
-
- grouped_by_country.transform_values!(&:uniq)
-
- {
- countries_count: data[:countries].count,
- cities_count: data[:cities].count,
- grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
- year: year,
- modal_id: "countries_cities_modal_#{year}"
- }
- end
-
- def countries_and_cities_stat_for_month(stat)
- countries = stat.toponyms.count { _1['country'] }
- cities = stat.toponyms.sum { _1['cities'].count }
-
- "#{countries} countries, #{cities} cities"
- end
-
- def year_distance_stat(year, user)
- # Distance is now stored in meters, convert to user's preferred unit for display
- total_distance_meters = Stat.year_distance(year, user).sum { _1[1] }
- Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
- end
-
- def past?(year, month)
- DateTime.new(year, month).past?
- end
-
- def points_exist?(year, month, user)
- user.points.where(
- timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
- ).exists?
- end
-
def new_version_available?
CheckAppVersion.new.call
end
diff --git a/app/helpers/stats_helper.rb b/app/helpers/stats_helper.rb
new file mode 100644
index 00000000..ad0574b3
--- /dev/null
+++ b/app/helpers/stats_helper.rb
@@ -0,0 +1,221 @@
+# frozen_string_literal: true
+
+module StatsHelper
+ def year_distance_stat(year_data, user)
+ total_distance_meters = year_data.sum { _1[1] }
+
+ Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
+ end
+
+ def countries_and_cities_stat_for_year(year, stats)
+ data = { countries: [], cities: [] }
+
+ stats.select { _1.year == year }.each do
+ data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact
+ data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq
+ end
+
+ data[:cities].flatten!.uniq!
+ data[:countries].flatten!.uniq!
+
+ grouped_by_country = {}
+ stats.select { _1.year == year }.each do |stat|
+ stat.toponyms.flatten.each do |toponym|
+ country = toponym['country']
+ next if country.blank?
+
+ grouped_by_country[country] ||= []
+
+ next if toponym['cities'].blank?
+
+ toponym['cities'].each do |city_data|
+ city = city_data['city']
+ grouped_by_country[country] << city if city.present?
+ end
+ end
+ end
+
+ grouped_by_country.transform_values!(&:uniq)
+
+ {
+ countries_count: data[:countries].count,
+ cities_count: data[:cities].count,
+ grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
+ year: year,
+ modal_id: "countries_cities_modal_#{year}"
+ }
+ end
+
+ def countries_and_cities_stat_for_month(stat)
+ countries = stat.toponyms.count { _1['country'] }
+ cities = stat.toponyms.sum { _1['cities'].count }
+
+ "#{countries} countries, #{cities} cities"
+ end
+
+ def distance_traveled(user, stat)
+ distance_unit = user.safe_settings.distance_unit
+ value = Stat.convert_distance(stat.distance, distance_unit).round
+
+ "#{number_with_delimiter(value)} #{distance_unit}"
+ end
+
+ def x_than_average_distance(stat, average_distance_this_year)
+ return '' if average_distance_this_year&.zero?
+
+ current_km = stat.distance / 1000.0
+ difference = current_km - average_distance_this_year.to_f
+ percentage = ((difference / average_distance_this_year.to_f) * 100).round
+
+ more_or_less = difference.positive? ? 'more' : 'less'
+ "#{percentage.abs}% #{more_or_less} than your average this year"
+ end
+
+ def x_than_previous_active_days(stat, previous_stat)
+ return '' unless previous_stat
+
+ previous_active_days = previous_stat.daily_distance.select { _1[1].positive? }.count
+ current_active_days = stat.daily_distance.select { _1[1].positive? }.count
+ difference = current_active_days - previous_active_days
+
+ return 'Same as previous month' if difference.zero?
+
+ more_or_less = difference.positive? ? 'more' : 'less'
+ days_word = pluralize(difference.abs, 'day')
+
+ "#{days_word} #{more_or_less} than previous month"
+ end
+
+ def active_days(stat)
+ total_days = stat.daily_distance.count
+ active_days = stat.daily_distance.select { _1[1].positive? }.count
+
+ "#{active_days}/#{total_days}"
+ end
+
+ def countries_visited(stat)
+ stat.toponyms.count { _1['country'] }
+ end
+
+ def x_than_previous_countries_visited(stat, previous_stat)
+ return '' unless previous_stat
+
+ previous_countries = previous_stat.toponyms.count { _1['country'] }
+ current_countries = stat.toponyms.count { _1['country'] }
+ difference = current_countries - previous_countries
+
+ return 'Same as previous month' if difference.zero?
+
+ more_or_less = difference.positive? ? 'more' : 'less'
+ countries_word = pluralize(difference.abs, 'country')
+
+ "#{countries_word} #{more_or_less} than previous month"
+ end
+
+ def peak_day(stat)
+ peak = stat.daily_distance.max_by { _1[1] }
+ return 'N/A' unless peak && peak[1].positive?
+
+ date = Date.new(stat.year, stat.month, peak[0])
+ distance_unit = stat.user.safe_settings.distance_unit
+
+ distance_value = Stat.convert_distance(peak[1], distance_unit).round
+ text = "#{date.strftime('%B %d')} (#{distance_value} #{distance_unit})"
+
+ link_to text, map_url(start_at: date.beginning_of_day, end_at: date.end_of_day), class: 'underline'
+ end
+
+ def quietest_week(stat)
+ return 'N/A' if stat.daily_distance.empty?
+
+ # Create a hash with date as key and distance as value
+ distance_by_date = stat.daily_distance.to_h.transform_keys do |timestamp|
+ Time.at(timestamp).in_time_zone(stat.user.timezone || 'UTC').to_date
+ end
+
+ # Initialize variables to track the quietest week
+ quietest_start_date = nil
+ quietest_distance = Float::INFINITY
+
+ # Iterate through each day of the month to find the quietest week
+ start_date = distance_by_date.keys.min.beginning_of_month
+ end_date = distance_by_date.keys.max.end_of_month
+
+ (start_date..end_date).each_cons(7) do |week|
+ week_distance = week.sum { |date| distance_by_date[date] || 0 }
+
+ if week_distance < quietest_distance
+ quietest_distance = week_distance
+ quietest_start_date = week.first
+ end
+ end
+
+ return 'N/A' unless quietest_start_date
+
+ quietest_end_date = quietest_start_date + 6.days
+ start_str = quietest_start_date.strftime('%b %d')
+ end_str = quietest_end_date.strftime('%b %d')
+
+ "#{start_str} - #{end_str}"
+ end
+
+ def month_icon(stat)
+ case stat.month
+ when 1..2, 12 then 'snowflake'
+ when 3..5 then 'flower'
+ when 6..8 then 'tree-palm'
+ when 9..11 then 'leaf'
+ end
+ end
+
+ def month_color(stat)
+ case stat.month
+ when 1 then '#397bb5'
+ when 2 then '#5A4E9D'
+ when 3 then '#3B945E'
+ when 4 then '#7BC96F'
+ when 5 then '#FFD54F'
+ when 6 then '#FFA94D'
+ when 7 then '#FF6B6B'
+ when 8 then '#FF8C42'
+ when 9 then '#C97E4F'
+ when 10 then '#8B4513'
+ when 11 then '#5A2E2E'
+ when 12 then '#265d7d'
+ end
+ end
+
+ def month_gradient_classes(stat)
+ case stat.month
+ when 1 then 'bg-gradient-to-br from-blue-500 to-blue-800' # Winter blue
+ when 2 then 'bg-gradient-to-bl from-blue-600 to-purple-600' # Purple
+ when 3 then 'bg-gradient-to-tr from-green-400 to-green-700' # Spring green
+ when 4 then 'bg-gradient-to-tl from-green-500 to-green-700' # Light green
+ when 5 then 'bg-gradient-to-br from-yellow-400 to-yellow-600' # Spring yellow
+ when 6 then 'bg-gradient-to-bl from-orange-400 to-orange-600' # Summer orange
+ when 7 then 'bg-gradient-to-tr from-red-400 to-red-600' # Summer red
+ when 8 then 'bg-gradient-to-tl from-orange-600 to-red-400' # Orange-red
+ when 9 then 'bg-gradient-to-br from-orange-600 to-yellow-400' # Autumn orange
+ when 10 then 'bg-gradient-to-bl from-yellow-700 to-orange-700' # Autumn brown
+ when 11 then 'bg-gradient-to-tr from-red-800 to-red-900' # Dark red
+ when 12 then 'bg-gradient-to-tl from-blue-600 to-blue-700' # Winter dark blue
+ end
+ end
+
+ def month_bg_image(stat)
+ case stat.month
+ when 1 then image_url('backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg')
+ when 2 then image_url('backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg')
+ when 3 then image_url('backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg')
+ when 4 then image_url('backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg')
+ when 5 then image_url('backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg')
+ when 6 then image_url('backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg')
+ when 7 then image_url('backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg')
+ when 8 then image_url('backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg')
+ when 9 then image_url('backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg')
+ when 10 then image_url('backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg')
+ when 11 then image_url('backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg')
+ when 12 then image_url('backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg')
+ end
+ end
+end
diff --git a/app/javascript/controllers/public_stat_map_controller.js b/app/javascript/controllers/public_stat_map_controller.js
new file mode 100644
index 00000000..a6a534fd
--- /dev/null
+++ b/app/javascript/controllers/public_stat_map_controller.js
@@ -0,0 +1,309 @@
+import L from "leaflet";
+import { createHexagonGrid } from "../maps/hexagon_grid";
+import { createAllMapLayers } from "../maps/layers";
+import BaseController from "./base_controller";
+
+export default class extends BaseController {
+ static targets = ["container"];
+ static values = {
+ year: Number,
+ month: Number,
+ uuid: String,
+ dataBounds: Object,
+ selfHosted: String
+ };
+
+ connect() {
+ super.connect();
+ console.log('🏁 Controller connected - loading overlay should be visible');
+ this.selfHosted = this.selfHostedValue || 'false';
+ this.initializeMap();
+ this.loadHexagons();
+ }
+
+ disconnect() {
+ if (this.hexagonGrid) {
+ this.hexagonGrid.destroy();
+ }
+ if (this.map) {
+ this.map.remove();
+ }
+ }
+
+ initializeMap() {
+ // Initialize map with interactive controls enabled
+ this.map = L.map(this.element, {
+ zoomControl: true,
+ scrollWheelZoom: true,
+ doubleClickZoom: true,
+ touchZoom: true,
+ dragging: true,
+ keyboard: false
+ });
+
+ // Add dynamic tile layer based on self-hosted setting
+ this.addMapLayers();
+
+ // Default view
+ this.map.setView([40.0, -100.0], 4);
+ }
+
+ addMapLayers() {
+ try {
+ // Use appropriate default layer based on self-hosted mode
+ const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
+ const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
+
+ // If no layers were created, fall back to OSM
+ if (Object.keys(maps).length === 0) {
+ console.warn('No map layers available, falling back to OSM');
+ this.addFallbackOSMLayer();
+ }
+ } catch (error) {
+ console.error('Error creating map layers:', error);
+ console.log('Falling back to OSM tile layer');
+ this.addFallbackOSMLayer();
+ }
+ }
+
+ addFallbackOSMLayer() {
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ attribution: '© OpenStreetMap contributors',
+ maxZoom: 15
+ }).addTo(this.map);
+ }
+
+ async loadHexagons() {
+ console.log('🎯 loadHexagons started - checking overlay state');
+ const initialLoadingElement = document.getElementById('map-loading');
+ console.log('📊 Initial overlay display:', initialLoadingElement?.style.display || 'default');
+
+ try {
+ // Use server-provided data bounds
+ const dataBounds = this.dataBoundsValue;
+
+ if (dataBounds && dataBounds.point_count > 0) {
+ // Set map view to data bounds BEFORE creating hexagon grid
+ this.map.fitBounds([
+ [dataBounds.min_lat, dataBounds.min_lng],
+ [dataBounds.max_lat, dataBounds.max_lng]
+ ], { padding: [20, 20] });
+
+ // Wait for the map to finish fitting bounds
+ console.log('⏳ About to wait for map moveend - overlay should still be visible');
+ await new Promise(resolve => {
+ this.map.once('moveend', resolve);
+ // Fallback timeout in case moveend doesn't fire
+ setTimeout(resolve, 1000);
+ });
+ console.log('✅ Map fitBounds complete - checking overlay state');
+ const afterFitBoundsElement = document.getElementById('map-loading');
+ console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default');
+ }
+
+ this.hexagonGrid = createHexagonGrid(this.map, {
+ apiEndpoint: '/api/v1/maps/hexagons',
+ style: {
+ fillColor: '#3388ff',
+ fillOpacity: 0.3,
+ color: '#3388ff',
+ weight: 1,
+ opacity: 0.7
+ },
+ debounceDelay: 300,
+ maxZoom: 15,
+ minZoom: 4
+ });
+
+ // Force hide immediately after creation to prevent auto-showing
+ this.hexagonGrid.hide();
+
+ // Disable all dynamic behavior by removing event listeners
+ this.map.off('moveend');
+ this.map.off('zoomend');
+
+ // Load hexagons only once on page load (static behavior)
+ // NOTE: Do NOT hide loading overlay here - let loadStaticHexagons() handle it
+ if (dataBounds && dataBounds.point_count > 0) {
+ await this.loadStaticHexagons();
+ } else {
+ console.warn('No data bounds or points available - not showing hexagons');
+ // Only hide loading indicator if no hexagons to load
+ const loadingElement = document.getElementById('map-loading');
+ if (loadingElement) {
+ loadingElement.style.display = 'none';
+ }
+ }
+
+ } catch (error) {
+ console.error('Error initializing hexagon grid:', error);
+
+ // Hide loading indicator on initialization error
+ const loadingElement = document.getElementById('map-loading');
+ if (loadingElement) {
+ loadingElement.style.display = 'none';
+ }
+ }
+
+ // Do NOT hide loading overlay here - let loadStaticHexagons() handle it completely
+ }
+
+ async loadStaticHexagons() {
+ console.log('🔄 Loading static hexagons for public sharing...');
+
+ // Ensure loading overlay is visible and disable map interaction
+ const loadingElement = document.getElementById('map-loading');
+ console.log('🔍 Loading element found:', !!loadingElement);
+ if (loadingElement) {
+ loadingElement.style.display = 'flex';
+ loadingElement.style.visibility = 'visible';
+ loadingElement.style.zIndex = '9999';
+ console.log('👁️ Loading overlay ENSURED visible - should be visible now');
+ }
+
+ // Disable map interaction during loading
+ this.map.dragging.disable();
+ this.map.touchZoom.disable();
+ this.map.doubleClickZoom.disable();
+ this.map.scrollWheelZoom.disable();
+ this.map.boxZoom.disable();
+ this.map.keyboard.disable();
+ if (this.map.tap) this.map.tap.disable();
+
+ // Add delay to ensure loading overlay is visible
+ await new Promise(resolve => setTimeout(resolve, 500));
+
+ try {
+ // Calculate date range for the month
+ const startDate = new Date(this.yearValue, this.monthValue - 1, 1);
+ const endDate = new Date(this.yearValue, this.monthValue, 0, 23, 59, 59);
+
+ // Use the full data bounds for hexagon request (not current map viewport)
+ const dataBounds = this.dataBoundsValue;
+
+ const params = new URLSearchParams({
+ min_lon: dataBounds.min_lng,
+ min_lat: dataBounds.min_lat,
+ max_lon: dataBounds.max_lng,
+ max_lat: dataBounds.max_lat,
+ hex_size: 1000, // Fixed 1km hexagons
+ start_date: startDate.toISOString(),
+ end_date: endDate.toISOString(),
+ uuid: this.uuidValue
+ });
+
+ const url = `/api/v1/maps/hexagons?${params}`;
+ console.log('📍 Fetching static hexagons from:', url);
+
+ const response = await fetch(url, {
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('Hexagon API error:', response.status, response.statusText, errorText);
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const geojsonData = await response.json();
+ console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`);
+
+ // Add hexagons directly to map as a static layer
+ if (geojsonData.features && geojsonData.features.length > 0) {
+ this.addStaticHexagonsToMap(geojsonData);
+ }
+
+ } catch (error) {
+ console.error('Failed to load static hexagons:', error);
+ } finally {
+ // Re-enable map interaction after loading (success or failure)
+ this.map.dragging.enable();
+ this.map.touchZoom.enable();
+ this.map.doubleClickZoom.enable();
+ this.map.scrollWheelZoom.enable();
+ this.map.boxZoom.enable();
+ this.map.keyboard.enable();
+ if (this.map.tap) this.map.tap.enable();
+
+ // Hide loading overlay
+ const loadingElement = document.getElementById('map-loading');
+ if (loadingElement) {
+ loadingElement.style.display = 'none';
+ console.log('🚫 Loading overlay hidden - hexagons are fully loaded');
+ }
+ }
+ }
+
+ addStaticHexagonsToMap(geojsonData) {
+ // Calculate max point count for color scaling
+ const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
+
+ const staticHexagonLayer = L.geoJSON(geojsonData, {
+ style: (feature) => this.styleHexagon(),
+ onEachFeature: (feature, layer) => {
+ // Add popup with statistics
+ const props = feature.properties;
+ const popupContent = this.buildPopupContent(props);
+ layer.bindPopup(popupContent);
+
+ // Add hover effects
+ layer.on({
+ mouseover: (e) => this.onHexagonMouseOver(e),
+ mouseout: (e) => this.onHexagonMouseOut(e)
+ });
+ }
+ });
+
+ staticHexagonLayer.addTo(this.map);
+ }
+
+ styleHexagon() {
+ return {
+ fillColor: '#3388ff',
+ fillOpacity: 0.3,
+ color: '#3388ff',
+ weight: 1,
+ opacity: 0.3
+ };
+ }
+
+ buildPopupContent(props) {
+ const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
+ const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
+
+ return `
+
+ Date Range:
+ ${startDate} - ${endDate}
+
+ `;
+ }
+
+ onHexagonMouseOver(e) {
+ const layer = e.target;
+ // Store original style before changing
+ if (!layer._originalStyle) {
+ layer._originalStyle = {
+ fillOpacity: layer.options.fillOpacity,
+ weight: layer.options.weight,
+ opacity: layer.options.opacity
+ };
+ }
+
+ layer.setStyle({
+ fillOpacity: 0.8,
+ weight: 2,
+ opacity: 1.0
+ });
+ }
+
+ onHexagonMouseOut(e) {
+ const layer = e.target;
+ // Reset to stored original style
+ if (layer._originalStyle) {
+ layer.setStyle(layer._originalStyle);
+ }
+ }
+}
diff --git a/app/javascript/controllers/sharing_modal_controller.js b/app/javascript/controllers/sharing_modal_controller.js
new file mode 100644
index 00000000..eb6e9ade
--- /dev/null
+++ b/app/javascript/controllers/sharing_modal_controller.js
@@ -0,0 +1,131 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["enableToggle", "expirationSettings", "sharingLink", "loading", "expirationSelect"]
+ static values = { url: String }
+
+ connect() {
+ console.log("Sharing modal controller connected")
+ }
+
+ toggleSharing() {
+ const isEnabled = this.enableToggleTarget.checked
+
+ if (isEnabled) {
+ this.expirationSettingsTarget.classList.remove("hidden")
+ this.saveSettings() // Save immediately when enabling
+ } else {
+ this.expirationSettingsTarget.classList.add("hidden")
+ this.sharingLinkTarget.value = ""
+ this.saveSettings() // Save immediately when disabling
+ }
+ }
+
+ expirationChanged() {
+ // Save settings immediately when expiration changes
+ if (this.enableToggleTarget.checked) {
+ this.saveSettings()
+ }
+ }
+
+ saveSettings() {
+ // Show loading state
+ this.showLoadingState()
+
+ const formData = new FormData()
+ formData.append('enabled', this.enableToggleTarget.checked ? '1' : '0')
+
+ if (this.enableToggleTarget.checked && this.hasExpirationSelectTarget) {
+ formData.append('expiration', this.expirationSelectTarget.value || '1h')
+ } else if (this.enableToggleTarget.checked) {
+ formData.append('expiration', '1h')
+ }
+
+ // Use the URL value from the controller
+ const url = this.urlValue
+
+ fetch(url, {
+ method: 'PATCH',
+ headers: {
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
+ 'X-Requested-With': 'XMLHttpRequest'
+ },
+ body: formData
+ })
+ .then(response => response.json())
+ .then(data => {
+ this.hideLoadingState()
+
+ if (data.success) {
+ // Update sharing link if provided
+ if (data.sharing_url) {
+ this.sharingLinkTarget.value = data.sharing_url
+ }
+
+ // Show a subtle notification for auto-save
+ this.showNotification("✓ Auto-saved", "success")
+ } else {
+ this.showNotification("Failed to save settings. Please try again.", "error")
+ }
+ })
+ .catch(error => {
+ console.error('Error:', error)
+ this.hideLoadingState()
+ this.showNotification("Failed to save settings. Please try again.", "error")
+ })
+ }
+
+ showLoadingState() {
+ if (this.hasLoadingTarget) {
+ this.loadingTarget.classList.remove("hidden")
+ }
+ }
+
+ hideLoadingState() {
+ if (this.hasLoadingTarget) {
+ this.loadingTarget.classList.add("hidden")
+ }
+ }
+
+ async copyLink() {
+ try {
+ await navigator.clipboard.writeText(this.sharingLinkTarget.value)
+
+ // Show temporary success feedback
+ const button = this.sharingLinkTarget.nextElementSibling
+ const originalText = button.innerHTML
+ button.innerHTML = "✅ Copied!"
+ button.classList.add("btn-success")
+
+ setTimeout(() => {
+ button.innerHTML = originalText
+ button.classList.remove("btn-success")
+ }, 2000)
+
+ } catch (err) {
+ console.error("Failed to copy: ", err)
+
+ // Fallback: select the text
+ this.sharingLinkTarget.select()
+ this.sharingLinkTarget.setSelectionRange(0, 99999) // For mobile devices
+ }
+ }
+
+ showNotification(message, type) {
+ // Create a simple toast notification
+ const toast = document.createElement('div')
+ toast.className = `toast toast-top toast-end z-50`
+ toast.innerHTML = `
+
+ ${message}
+
+ `
+
+ document.body.appendChild(toast)
+
+ // Remove after 3 seconds
+ setTimeout(() => {
+ toast.remove()
+ }, 3000)
+ }
+}
diff --git a/app/javascript/controllers/stat_page_controller.js b/app/javascript/controllers/stat_page_controller.js
new file mode 100644
index 00000000..97b54b24
--- /dev/null
+++ b/app/javascript/controllers/stat_page_controller.js
@@ -0,0 +1,287 @@
+import L from "leaflet";
+import "leaflet.heat";
+import { createAllMapLayers } from "../maps/layers";
+import BaseController from "./base_controller";
+
+export default class extends BaseController {
+ static targets = ["map", "loading", "heatmapBtn", "pointsBtn"];
+
+ connect() {
+ super.connect();
+ console.log("StatPage controller connected");
+
+ // Get data attributes from the element (will be passed from the view)
+ this.year = parseInt(this.element.dataset.year || new Date().getFullYear());
+ this.month = parseInt(this.element.dataset.month || new Date().getMonth() + 1);
+ this.apiKey = this.element.dataset.apiKey;
+ this.selfHosted = this.element.dataset.selfHosted || this.selfHostedValue;
+
+ console.log(`Loading data for ${this.month}/${this.year} with API key: ${this.apiKey ? 'present' : 'missing'}`);
+
+ // Initialize map after a short delay to ensure container is ready
+ setTimeout(() => {
+ this.initializeMap();
+ }, 100);
+ }
+
+ disconnect() {
+ if (this.map) {
+ this.map.remove();
+ }
+ console.log("StatPage controller disconnected");
+ }
+
+ initializeMap() {
+ if (!this.mapTarget) {
+ console.error("Map target not found");
+ return;
+ }
+
+ try {
+ // Initialize Leaflet map
+ this.map = L.map(this.mapTarget, {
+ zoomControl: true,
+ scrollWheelZoom: true,
+ doubleClickZoom: true,
+ boxZoom: false,
+ keyboard: false,
+ dragging: true,
+ touchZoom: true
+ }).setView([52.520008, 13.404954], 10); // Default to Berlin
+
+ // Add dynamic tile layer based on self-hosted setting
+ this.addMapLayers();
+
+ // Add small scale control
+ L.control.scale({
+ position: 'bottomright',
+ maxWidth: 100,
+ imperial: true,
+ metric: true
+ }).addTo(this.map);
+
+ // Initialize layers
+ this.markersLayer = L.layerGroup(); // Don't add to map initially
+ this.heatmapLayer = null;
+
+ // Load data for this month
+ this.loadMonthData();
+
+ } catch (error) {
+ console.error("Error initializing map:", error);
+ this.showError("Failed to initialize map");
+ }
+ }
+
+ async loadMonthData() {
+ try {
+ // Show loading
+ this.showLoading(true);
+
+ // Calculate date range for the month
+ const startDate = `${this.year}-${this.month.toString().padStart(2, '0')}-01T00:00:00`;
+ const lastDay = new Date(this.year, this.month, 0).getDate();
+ const endDate = `${this.year}-${this.month.toString().padStart(2, '0')}-${lastDay}T23:59:59`;
+
+ console.log(`Fetching points from ${startDate} to ${endDate}`);
+
+ // Fetch points data for the month using Authorization header
+ const response = await fetch(`/api/v1/points?start_at=${encodeURIComponent(startDate)}&end_at=${encodeURIComponent(endDate)}&per_page=1000`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this.apiKey}`
+ }
+ });
+
+ if (!response.ok) {
+ console.error(`API request failed with status: ${response.status}`);
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ const data = await response.json();
+ console.log(`Received ${Array.isArray(data) ? data.length : 0} points from API`);
+
+ if (Array.isArray(data) && data.length > 0) {
+ this.processPointsData(data);
+ } else {
+ console.log("No points data available for this month");
+ this.showNoData();
+ }
+
+ } catch (error) {
+ console.error("Error loading month data:", error);
+ this.showError("Failed to load location data");
+ // Don't fallback to mock data - show the error instead
+ } finally {
+ this.showLoading(false);
+ }
+ }
+
+ processPointsData(points) {
+ console.log(`Processing ${points.length} points for ${this.month}/${this.year}`);
+
+ // Clear existing markers
+ this.markersLayer.clearLayers();
+
+ // Convert points to markers (API returns latitude/longitude as strings)
+ const markers = points.map(point => {
+ const lat = parseFloat(point.latitude);
+ const lng = parseFloat(point.longitude);
+
+ return L.circleMarker([lat, lng], {
+ radius: 3,
+ fillColor: '#570df8',
+ color: '#570df8',
+ weight: 1,
+ opacity: 0.8,
+ fillOpacity: 0.6
+ });
+ });
+
+ // Add markers to layer (but don't add to map yet)
+ markers.forEach(marker => {
+ this.markersLayer.addLayer(marker);
+ });
+
+ // Prepare data for heatmap (convert strings to numbers)
+ this.heatmapData = points.map(point => [
+ parseFloat(point.latitude),
+ parseFloat(point.longitude),
+ 0.5
+ ]);
+
+ // Show heatmap by default
+ if (this.heatmapData.length > 0) {
+ this.heatmapLayer = L.heatLayer(this.heatmapData, {
+ radius: 25,
+ blur: 15,
+ maxZoom: 17,
+ max: 1.0
+ }).addTo(this.map);
+
+ // Set button states
+ this.heatmapBtnTarget.classList.add('btn-active');
+ this.pointsBtnTarget.classList.remove('btn-active');
+ }
+
+ // Fit map to show all points
+ if (points.length > 0) {
+ const group = new L.featureGroup(markers);
+ this.map.fitBounds(group.getBounds().pad(0.1));
+ }
+
+ console.log("Points processed successfully");
+ }
+
+ toggleHeatmap() {
+ if (!this.heatmapData || this.heatmapData.length === 0) {
+ console.warn("No heatmap data available");
+ return;
+ }
+
+ if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) {
+ // Remove heatmap
+ this.map.removeLayer(this.heatmapLayer);
+ this.heatmapLayer = null;
+ this.heatmapBtnTarget.classList.remove('btn-active');
+
+ // Show points
+ if (!this.map.hasLayer(this.markersLayer)) {
+ this.map.addLayer(this.markersLayer);
+ this.pointsBtnTarget.classList.add('btn-active');
+ }
+ } else {
+ // Add heatmap
+ this.heatmapLayer = L.heatLayer(this.heatmapData, {
+ radius: 25,
+ blur: 15,
+ maxZoom: 17,
+ max: 1.0
+ }).addTo(this.map);
+
+ this.heatmapBtnTarget.classList.add('btn-active');
+
+ // Hide points
+ if (this.map.hasLayer(this.markersLayer)) {
+ this.map.removeLayer(this.markersLayer);
+ this.pointsBtnTarget.classList.remove('btn-active');
+ }
+ }
+ }
+
+ togglePoints() {
+ if (this.map.hasLayer(this.markersLayer)) {
+ // Remove points
+ this.map.removeLayer(this.markersLayer);
+ this.pointsBtnTarget.classList.remove('btn-active');
+ } else {
+ // Add points
+ this.map.addLayer(this.markersLayer);
+ this.pointsBtnTarget.classList.add('btn-active');
+
+ // Remove heatmap if active
+ if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) {
+ this.map.removeLayer(this.heatmapLayer);
+ this.heatmapBtnTarget.classList.remove('btn-active');
+ }
+ }
+ }
+
+ showLoading(show) {
+ if (this.hasLoadingTarget) {
+ this.loadingTarget.style.display = show ? 'flex' : 'none';
+ }
+ }
+
+ showError(message) {
+ console.error(message);
+ if (this.hasLoadingTarget) {
+ this.loadingTarget.innerHTML = `
+
+ `;
+ this.loadingTarget.style.display = 'flex';
+ }
+ }
+
+ showNoData() {
+ console.log("No data available for this month");
+ if (this.hasLoadingTarget) {
+ this.loadingTarget.innerHTML = `
+
+
+
No location data available for ${new Date(this.year, this.month - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
+
+ `;
+ this.loadingTarget.style.display = 'flex';
+ }
+ }
+
+ addMapLayers() {
+ try {
+ // Use appropriate default layer based on self-hosted mode
+ const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light";
+ const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted);
+
+ // If no layers were created, fall back to OSM
+ if (Object.keys(maps).length === 0) {
+ console.warn('No map layers available, falling back to OSM');
+ this.addFallbackOSMLayer();
+ }
+ } catch (error) {
+ console.error('Error creating map layers:', error);
+ console.log('Falling back to OSM tile layer');
+ this.addFallbackOSMLayer();
+ }
+ }
+
+ addFallbackOSMLayer() {
+ L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(this.map);
+ }
+}
diff --git a/app/javascript/maps/hexagon_grid.js b/app/javascript/maps/hexagon_grid.js
new file mode 100644
index 00000000..87c2be93
--- /dev/null
+++ b/app/javascript/maps/hexagon_grid.js
@@ -0,0 +1,363 @@
+/**
+ * HexagonGrid - Manages hexagonal grid overlay on Leaflet maps
+ * Provides efficient loading and rendering of hexagon tiles based on viewport
+ */
+export class HexagonGrid {
+ constructor(map, options = {}) {
+ this.map = map;
+ this.options = {
+ apiEndpoint: '/api/v1/maps/hexagons',
+ style: {
+ fillColor: '#3388ff',
+ fillOpacity: 0.1,
+ color: '#3388ff',
+ weight: 1,
+ opacity: 0.5
+ },
+ debounceDelay: 300, // ms to wait before loading new hexagons
+ maxZoom: 18, // Don't show hexagons beyond this zoom level
+ minZoom: 8, // Don't show hexagons below this zoom level
+ ...options
+ };
+
+ this.hexagonLayer = null;
+ this.loadingController = null; // For aborting requests
+ this.lastBounds = null;
+ this.isVisible = false;
+
+ this.init();
+ }
+
+ init() {
+ // Create the hexagon layer group
+ this.hexagonLayer = L.layerGroup();
+
+ // Bind map events
+ this.map.on('moveend', this.debounce(this.onMapMove.bind(this), this.options.debounceDelay));
+ this.map.on('zoomend', this.onZoomChange.bind(this));
+
+ // Initial load if within zoom range
+ if (this.shouldShowHexagons()) {
+ this.show();
+ }
+ }
+
+ /**
+ * Show the hexagon grid overlay
+ */
+ show() {
+ if (!this.isVisible) {
+ this.isVisible = true;
+ if (this.shouldShowHexagons()) {
+ this.hexagonLayer.addTo(this.map);
+ this.loadHexagons();
+ }
+ }
+ }
+
+ /**
+ * Hide the hexagon grid overlay
+ */
+ hide() {
+ if (this.isVisible) {
+ this.isVisible = false;
+ this.hexagonLayer.remove();
+ this.cancelPendingRequest();
+ }
+ }
+
+ /**
+ * Toggle visibility of hexagon grid
+ */
+ toggle() {
+ if (this.isVisible) {
+ this.hide();
+ } else {
+ this.show();
+ }
+ }
+
+ /**
+ * Check if hexagons should be displayed at current zoom level
+ */
+ shouldShowHexagons() {
+ const zoom = this.map.getZoom();
+ return zoom >= this.options.minZoom && zoom <= this.options.maxZoom;
+ }
+
+ /**
+ * Handle map move events
+ */
+ onMapMove() {
+ if (!this.isVisible || !this.shouldShowHexagons()) {
+ return;
+ }
+
+ const currentBounds = this.map.getBounds();
+
+ // Only reload if bounds have changed significantly
+ if (this.boundsChanged(currentBounds)) {
+ this.loadHexagons();
+ }
+ }
+
+ /**
+ * Handle zoom change events
+ */
+ onZoomChange() {
+ if (!this.isVisible) {
+ return;
+ }
+
+ if (this.shouldShowHexagons()) {
+ // Show hexagons and load for new zoom level
+ if (!this.map.hasLayer(this.hexagonLayer)) {
+ this.hexagonLayer.addTo(this.map);
+ }
+ this.loadHexagons();
+ } else {
+ // Hide hexagons when zoomed too far in/out
+ this.hexagonLayer.remove();
+ this.cancelPendingRequest();
+ }
+ }
+
+ /**
+ * Check if bounds have changed enough to warrant reloading
+ */
+ boundsChanged(newBounds) {
+ if (!this.lastBounds) {
+ return true;
+ }
+
+ const threshold = 0.1; // 10% change threshold
+ const oldArea = this.getBoundsArea(this.lastBounds);
+ const newArea = this.getBoundsArea(newBounds);
+ const intersection = this.getBoundsIntersection(this.lastBounds, newBounds);
+ const intersectionRatio = intersection / Math.min(oldArea, newArea);
+
+ return intersectionRatio < (1 - threshold);
+ }
+
+ /**
+ * Calculate approximate area of bounds
+ */
+ getBoundsArea(bounds) {
+ const sw = bounds.getSouthWest();
+ const ne = bounds.getNorthEast();
+ return (ne.lat - sw.lat) * (ne.lng - sw.lng);
+ }
+
+ /**
+ * Calculate intersection area between two bounds
+ */
+ getBoundsIntersection(bounds1, bounds2) {
+ const sw1 = bounds1.getSouthWest();
+ const ne1 = bounds1.getNorthEast();
+ const sw2 = bounds2.getSouthWest();
+ const ne2 = bounds2.getNorthEast();
+
+ const left = Math.max(sw1.lng, sw2.lng);
+ const right = Math.min(ne1.lng, ne2.lng);
+ const bottom = Math.max(sw1.lat, sw2.lat);
+ const top = Math.min(ne1.lat, ne2.lat);
+
+ if (left < right && bottom < top) {
+ return (right - left) * (top - bottom);
+ }
+ return 0;
+ }
+
+ /**
+ * Load hexagons for current viewport
+ */
+ async loadHexagons() {
+ console.log('❌ Using ORIGINAL loadHexagons method (should not happen for public sharing)');
+
+ // Cancel any pending request
+ this.cancelPendingRequest();
+
+ const bounds = this.map.getBounds();
+ this.lastBounds = bounds;
+
+ // Create new AbortController for this request
+ this.loadingController = new AbortController();
+
+ try {
+ // Get current date range from URL parameters
+ const urlParams = new URLSearchParams(window.location.search);
+ const startDate = urlParams.get('start_at');
+ const endDate = urlParams.get('end_at');
+
+ // Get viewport dimensions
+ const mapContainer = this.map.getContainer();
+ const viewportWidth = mapContainer.offsetWidth;
+ const viewportHeight = mapContainer.offsetHeight;
+
+ const params = new URLSearchParams({
+ min_lon: bounds.getWest(),
+ min_lat: bounds.getSouth(),
+ max_lon: bounds.getEast(),
+ max_lat: bounds.getNorth(),
+ viewport_width: viewportWidth,
+ viewport_height: viewportHeight
+ });
+
+ // Add date parameters if they exist
+ if (startDate) params.append('start_date', startDate);
+ if (endDate) params.append('end_date', endDate);
+
+ const response = await fetch(`${this.options.apiEndpoint}?${params}`, {
+ signal: this.loadingController.signal,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const geojsonData = await response.json();
+
+ // Clear existing hexagons and add new ones
+ this.clearHexagons();
+ this.addHexagonsToMap(geojsonData);
+
+ } catch (error) {
+ if (error.name !== 'AbortError') {
+ console.error('Failed to load hexagons:', error);
+ // Optionally show user-friendly error message
+ }
+ } finally {
+ this.loadingController = null;
+ }
+ }
+
+ /**
+ * Cancel pending hexagon loading request
+ */
+ cancelPendingRequest() {
+ if (this.loadingController) {
+ this.loadingController.abort();
+ this.loadingController = null;
+ }
+ }
+
+ /**
+ * Clear existing hexagons from the map
+ */
+ clearHexagons() {
+ this.hexagonLayer.clearLayers();
+ }
+
+ /**
+ * Add hexagons to the map from GeoJSON data
+ */
+ addHexagonsToMap(geojsonData) {
+ if (!geojsonData.features || geojsonData.features.length === 0) {
+ return;
+ }
+
+ // Calculate max point count for color scaling
+ const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count));
+
+ const geoJsonLayer = L.geoJSON(geojsonData, {
+ style: (feature) => this.styleHexagonByData(feature, maxPoints),
+ onEachFeature: (feature, layer) => {
+ // Add popup with statistics
+ const props = feature.properties;
+ const popupContent = this.buildPopupContent(props);
+ layer.bindPopup(popupContent);
+ }
+ });
+
+ geoJsonLayer.addTo(this.hexagonLayer);
+ }
+
+ /**
+ * Style hexagon based on point density and other data
+ */
+ styleHexagonByData(feature, maxPoints) {
+ const props = feature.properties;
+ const pointCount = props.point_count || 0;
+
+ // Calculate opacity based on point density (0.2 to 0.8)
+ const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
+
+ let color = '#3388ff'
+
+ return {
+ fillColor: color,
+ fillOpacity: opacity,
+ color: color,
+ weight: 1,
+ opacity: opacity + 0.2
+ };
+ }
+
+ /**
+ * Build popup content with hexagon statistics
+ */
+ buildPopupContent(props) {
+ const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
+ const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
+
+ return `
+
+ Date Range:
+ ${startDate} - ${endDate}
+
+ `;
+ }
+
+ /**
+ * Update hexagon style
+ */
+ updateStyle(newStyle) {
+ this.options.style = { ...this.options.style, ...newStyle };
+
+ // Update existing hexagons
+ this.hexagonLayer.eachLayer((layer) => {
+ if (layer.setStyle) {
+ layer.setStyle(this.options.style);
+ }
+ });
+ }
+
+ /**
+ * Destroy the hexagon grid and clean up
+ */
+ destroy() {
+ this.hide();
+ this.map.off('moveend');
+ this.map.off('zoomend');
+ this.hexagonLayer = null;
+ this.lastBounds = null;
+ }
+
+ /**
+ * Simple debounce utility
+ */
+ debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+ }
+}
+
+/**
+ * Create and return a new HexagonGrid instance
+ */
+export function createHexagonGrid(map, options = {}) {
+ return new HexagonGrid(map, options);
+}
+
+// Default export
+export default HexagonGrid;
diff --git a/app/jobs/bulk_stats_calculating_job.rb b/app/jobs/bulk_stats_calculating_job.rb
index 4311a361..8decdf11 100644
--- a/app/jobs/bulk_stats_calculating_job.rb
+++ b/app/jobs/bulk_stats_calculating_job.rb
@@ -4,7 +4,7 @@ class BulkStatsCalculatingJob < ApplicationJob
queue_as :stats
def perform
- user_ids = User.active.pluck(:id)
+ user_ids = User.active.pluck(:id) + User.trial.pluck(:id)
user_ids.each do |user_id|
Stats::BulkCalculator.new(user_id).call
diff --git a/app/jobs/cache/preheating_job.rb b/app/jobs/cache/preheating_job.rb
index 741f9014..c8002fdf 100644
--- a/app/jobs/cache/preheating_job.rb
+++ b/app/jobs/cache/preheating_job.rb
@@ -10,6 +10,24 @@ class Cache::PreheatingJob < ApplicationJob
user.years_tracked,
expires_in: 1.day
)
+
+ Rails.cache.write(
+ "dawarich/user_#{user.id}_points_geocoded_stats",
+ StatsQuery.new(user).cached_points_geocoded_stats,
+ expires_in: 1.day
+ )
+
+ Rails.cache.write(
+ "dawarich/user_#{user.id}_countries_visited",
+ user.countries_visited_uncached,
+ expires_in: 1.day
+ )
+
+ Rails.cache.write(
+ "dawarich/user_#{user.id}_cities_visited",
+ user.cities_visited_uncached,
+ expires_in: 1.day
+ )
end
end
end
diff --git a/app/jobs/tracks/cleanup_job.rb b/app/jobs/tracks/cleanup_job.rb
deleted file mode 100644
index 54851743..00000000
--- a/app/jobs/tracks/cleanup_job.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-# Lightweight cleanup job that runs weekly to catch any missed track generation.
-#
-# This provides a safety net while avoiding the overhead of daily bulk processing.
-class Tracks::CleanupJob < ApplicationJob
- queue_as :tracks
- sidekiq_options retry: false
-
- def perform(older_than: 1.day.ago)
- users_with_old_untracked_points(older_than).find_each do |user|
- # Process only the old untracked points
- Tracks::Generator.new(
- user,
- end_at: older_than,
- mode: :incremental
- ).call
- end
- end
-
- private
-
- def users_with_old_untracked_points(older_than)
- User.active.joins(:points)
- .where(points: { track_id: nil, timestamp: ..older_than.to_i })
- .having('COUNT(points.id) >= 2') # Only users with enough points for tracks
- .group(:id)
- end
-end
diff --git a/app/jobs/tracks/create_job.rb b/app/jobs/tracks/create_job.rb
deleted file mode 100644
index 537c2f39..00000000
--- a/app/jobs/tracks/create_job.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-class Tracks::CreateJob < ApplicationJob
- queue_as :tracks
-
- def perform(user_id, start_at: nil, end_at: nil, mode: :daily)
- user = User.find(user_id)
-
- Tracks::Generator.new(user, start_at:, end_at:, mode:).call
- rescue StandardError => e
- ExceptionReporter.call(e, 'Failed to create tracks for user')
- end
-end
diff --git a/app/jobs/tracks/daily_generation_job.rb b/app/jobs/tracks/daily_generation_job.rb
new file mode 100644
index 00000000..ba149f8a
--- /dev/null
+++ b/app/jobs/tracks/daily_generation_job.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+# Daily Track Generation Job
+#
+# Automatically processes new location points for all active/trial users on a regular schedule.
+# This job runs periodically (recommended: every 2-4 hours) to generate tracks from newly
+# received location data.
+#
+# Process:
+# 1. Iterates through all active or trial users
+# 2. For each user, finds the timestamp of their last track's end_at
+# 3. Checks if there are new points since that timestamp
+# 4. If new points exist, triggers parallel track generation using the existing system
+# 5. Uses the parallel generator with 'daily' mode for optimal performance
+#
+# The job leverages the existing parallel track generation infrastructure,
+# ensuring consistency with bulk operations while providing automatic daily processing.
+
+class Tracks::DailyGenerationJob < ApplicationJob
+ queue_as :tracks
+
+ def perform
+ User.active_or_trial.find_each do |user|
+ next if user.points_count.zero?
+
+ process_user_daily_tracks(user)
+ rescue StandardError => e
+ ExceptionReporter.call(e, "Failed to process daily tracks for user #{user.id}")
+ end
+ end
+
+ private
+
+ def process_user_daily_tracks(user)
+ start_timestamp = start_timestamp(user)
+
+ return unless user.points.where('timestamp >= ?', start_timestamp).exists?
+
+ Tracks::ParallelGeneratorJob.perform_later(
+ user.id,
+ start_at: start_timestamp,
+ end_at: Time.current.to_i,
+ mode: 'daily'
+ )
+ end
+
+ def start_timestamp(user)
+ last_end = user.tracks.maximum(:end_at)&.to_i
+ return last_end + 1 if last_end
+
+ user.points.minimum(:timestamp) || 1.week.ago.to_i
+ end
+end
diff --git a/app/jobs/tracks/incremental_check_job.rb b/app/jobs/tracks/incremental_check_job.rb
deleted file mode 100644
index 738246d6..00000000
--- a/app/jobs/tracks/incremental_check_job.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-class Tracks::IncrementalCheckJob < ApplicationJob
- queue_as :tracks
-
- def perform(user_id, point_id)
- user = User.find(user_id)
- point = Point.find(point_id)
-
- Tracks::IncrementalProcessor.new(user, point).call
- end
-end
diff --git a/app/jobs/tracks/parallel_generator_job.rb b/app/jobs/tracks/parallel_generator_job.rb
index 14ffb592..cc22afed 100644
--- a/app/jobs/tracks/parallel_generator_job.rb
+++ b/app/jobs/tracks/parallel_generator_job.rb
@@ -8,7 +8,7 @@ class Tracks::ParallelGeneratorJob < ApplicationJob
def perform(user_id, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day)
user = User.find(user_id)
- session = Tracks::ParallelGenerator.new(
+ Tracks::ParallelGenerator.new(
user,
start_at: start_at,
end_at: end_at,
diff --git a/app/jobs/tracks/time_chunk_processor_job.rb b/app/jobs/tracks/time_chunk_processor_job.rb
index d78923ca..0428bdb0 100644
--- a/app/jobs/tracks/time_chunk_processor_job.rb
+++ b/app/jobs/tracks/time_chunk_processor_job.rb
@@ -17,7 +17,6 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
tracks_created = process_chunk
update_session_progress(tracks_created)
-
rescue StandardError => e
ExceptionReporter.call(e, "Failed to process time chunk for user #{user_id}")
@@ -48,9 +47,7 @@ class Tracks::TimeChunkProcessorJob < ApplicationJob
# Create tracks from segments
tracks_created = 0
segments.each do |segment_points|
- if create_track_from_points_array(segment_points)
- tracks_created += 1
- end
+ tracks_created += 1 if create_track_from_points_array(segment_points)
end
tracks_created
diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb
index bbce993f..742db9eb 100644
--- a/app/jobs/users/mailer_sending_job.rb
+++ b/app/jobs/users/mailer_sending_job.rb
@@ -6,19 +6,46 @@ class Users::MailerSendingJob < ApplicationJob
def perform(user_id, email_type, **options)
user = User.find(user_id)
- if trial_related_email?(email_type) && user.active?
- Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed"
+ if should_skip_email?(user, email_type)
+ ExceptionReporter.call(
+ 'Users::MailerSendingJob',
+ "Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}"
+ )
+
return
end
params = { user: user }.merge(options)
UsersMailer.with(params).public_send(email_type).deliver_later
+ rescue ActiveRecord::RecordNotFound
+ ExceptionReporter.call(
+ 'Users::MailerSendingJob',
+ "User with ID #{user_id} not found. Skipping #{email_type} email."
+ )
end
private
- def trial_related_email?(email_type)
- %w[trial_expires_soon trial_expired].include?(email_type.to_s)
+ def should_skip_email?(user, email_type)
+ case email_type.to_s
+ when 'trial_expires_soon', 'trial_expired'
+ user.active?
+ when 'post_trial_reminder_early', 'post_trial_reminder_late'
+ user.active? || !user.trial?
+ else
+ false
+ end
+ end
+
+ def skip_reason(user, email_type)
+ case email_type.to_s
+ when 'trial_expires_soon', 'trial_expired'
+ 'user is already subscribed'
+ when 'post_trial_reminder_early', 'post_trial_reminder_late'
+ user.active? ? 'user is subscribed' : 'user is not in trial state'
+ else
+ 'unknown reason'
+ end
end
end
diff --git a/app/mailers/users_mailer.rb b/app/mailers/users_mailer.rb
index c7293a75..95afd3ea 100644
--- a/app/mailers/users_mailer.rb
+++ b/app/mailers/users_mailer.rb
@@ -2,26 +2,44 @@
class UsersMailer < ApplicationMailer
def welcome
+ # Sent after user signs up
@user = params[:user]
mail(to: @user.email, subject: 'Welcome to Dawarich!')
end
def explore_features
+ # Sent 2 days after user signs up
@user = params[:user]
mail(to: @user.email, subject: 'Explore Dawarich features!')
end
def trial_expires_soon
+ # Sent 2 days before trial expires
@user = params[:user]
mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days')
end
def trial_expired
+ # Sent when trial expires
@user = params[:user]
mail(to: @user.email, subject: '💔 Your Dawarich trial expired')
end
+
+ def post_trial_reminder_early
+ # Sent 2 days after trial expires
+ @user = params[:user]
+
+ mail(to: @user.email, subject: '🚀 Still interested in Dawarich? Subscribe now!')
+ end
+
+ def post_trial_reminder_late
+ # Sent 7 days after trial expires
+ @user = params[:user]
+
+ mail(to: @user.email, subject: '📍 Your location data is waiting - Subscribe to Dawarich')
+ end
end
diff --git a/app/models/point.rb b/app/models/point.rb
index 69e87681..2f1b9fef 100644
--- a/app/models/point.rb
+++ b/app/models/point.rb
@@ -17,7 +17,8 @@ class Point < ApplicationRecord
index: true
}
- enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 }, suffix: true
+ enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 },
+ suffix: true
enum :trigger, {
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,
report_location_message_event: 4, manual_event: 5, timer_based_event: 6,
@@ -33,7 +34,6 @@ class Point < ApplicationRecord
after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? }
after_create :set_country
after_create_commit :broadcast_coordinates
- # after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? }
# after_commit :recalculate_track, on: :update, if: -> { track.present? }
def self.without_raw_data
@@ -68,7 +68,7 @@ class Point < ApplicationRecord
def country_name
# TODO: Remove the country column in the future.
- read_attribute(:country_name) || self.country&.name || read_attribute(:country) || ''
+ read_attribute(:country_name) || country&.name || self[:country] || ''
end
private
@@ -101,8 +101,4 @@ class Point < ApplicationRecord
def recalculate_track
track.recalculate_path_and_distance!
end
-
- def trigger_incremental_track_generation
- Tracks::IncrementalCheckJob.perform_later(user.id, id)
- end
end
diff --git a/app/models/stat.rb b/app/models/stat.rb
index c69be6d0..bca5a455 100644
--- a/app/models/stat.rb
+++ b/app/models/stat.rb
@@ -7,6 +7,8 @@ class Stat < ApplicationRecord
belongs_to :user
+ before_create :generate_sharing_uuid
+
def distance_by_day
monthly_points = points
calculate_daily_distances(monthly_points)
@@ -30,8 +32,96 @@ class Stat < ApplicationRecord
.order(timestamp: :asc)
end
+ def sharing_enabled?
+ sharing_settings['enabled'] == true
+ end
+
+ def sharing_expired?
+ expiration = sharing_settings['expiration']
+ return false if expiration.blank? || expiration == 'permanent'
+
+ expires_at_value = sharing_settings['expires_at']
+ return true if expires_at_value.blank?
+
+ expires_at = begin
+ Time.zone.parse(expires_at_value)
+ rescue StandardError
+ nil
+ end
+
+ expires_at.present? ? Time.current > expires_at : true
+ end
+
+ def public_accessible?
+ sharing_enabled? && !sharing_expired?
+ end
+
+ def generate_new_sharing_uuid!
+ update!(sharing_uuid: SecureRandom.uuid)
+ end
+
+ def enable_sharing!(expiration: '1h')
+ expires_at = case expiration
+ when '1h' then 1.hour.from_now
+ when '12h' then 12.hours.from_now
+ when '24h' then 24.hours.from_now
+ end
+
+ update!(
+ sharing_settings: {
+ 'enabled' => true,
+ 'expiration' => expiration,
+ 'expires_at' => expires_at&.iso8601
+ },
+ sharing_uuid: sharing_uuid || SecureRandom.uuid
+ )
+ end
+
+ def disable_sharing!
+ update!(
+ sharing_settings: {
+ 'enabled' => false,
+ 'expiration' => nil,
+ 'expires_at' => nil
+ }
+ )
+ end
+
+ def calculate_data_bounds
+ start_date = Date.new(year, month, 1).beginning_of_day
+ end_date = start_date.end_of_month.end_of_day
+
+ points_relation = user.points.where(timestamp: start_date.to_i..end_date.to_i)
+ point_count = points_relation.count
+
+ return nil if point_count.zero?
+
+ bounds_result = ActiveRecord::Base.connection.exec_query(
+ "SELECT MIN(ST_Y(lonlat::geometry)) as min_lat, MAX(ST_Y(lonlat::geometry)) as max_lat,
+ MIN(ST_X(lonlat::geometry)) as min_lng, MAX(ST_X(lonlat::geometry)) as max_lng
+ FROM points
+ WHERE user_id = $1
+ AND timestamp BETWEEN $2 AND $3
+ AND lonlat IS NOT NULL",
+ 'data_bounds_query',
+ [user.id, start_date.to_i, end_date.to_i]
+ ).first
+
+ {
+ min_lat: bounds_result['min_lat'].to_f,
+ max_lat: bounds_result['max_lat'].to_f,
+ min_lng: bounds_result['min_lng'].to_f,
+ max_lng: bounds_result['max_lng'].to_f,
+ point_count: point_count
+ }
+ end
+
private
+ def generate_sharing_uuid
+ self.sharing_uuid ||= SecureRandom.uuid
+ end
+
def timespan
DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
end
@@ -40,8 +130,6 @@ class Stat < ApplicationRecord
Stats::DailyDistanceQuery.new(monthly_points, timespan, user_timezone).call
end
- private
-
def user_timezone
# Future: Once user.timezone column exists, uncomment the line below
# user.timezone.presence || Time.zone.name
diff --git a/app/models/user.rb b/app/models/user.rb
index 96d3e3a7..bde8e853 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -22,12 +22,13 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
before_save :sanitize_input
validates :email, presence: true
-
validates :reset_password_token, uniqueness: true, allow_nil: true
attribute :admin, :boolean, default: false
attribute :points_count, :integer, default: 0
+ scope :active_or_trial, -> { where(status: %i[active trial]) }
+
enum :status, { inactive: 0, active: 1, trial: 2 }
def safe_settings
@@ -35,15 +36,20 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
end
def countries_visited
- points
- .where.not(country_name: [nil, ''])
- .distinct
- .pluck(:country_name)
- .compact
+ Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do
+ points
+ .without_raw_data
+ .where.not(country_name: [nil, ''])
+ .distinct
+ .pluck(:country_name)
+ .compact
+ end
end
def cities_visited
- points.where.not(city: [nil, '']).distinct.pluck(:city).compact
+ Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do
+ points.where.not(city: [nil, '']).distinct.pluck(:city).compact
+ end
end
def total_distance
@@ -121,6 +127,23 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
(points_count || 0).zero? && trial?
end
+ def timezone
+ Time.zone.name
+ end
+
+ def countries_visited_uncached
+ points
+ .without_raw_data
+ .where.not(country_name: [nil, ''])
+ .distinct
+ .pluck(:country_name)
+ .compact
+ end
+
+ def cities_visited_uncached
+ points.where.not(city: [nil, '']).distinct.pluck(:city).compact
+ end
+
private
def create_api_key
@@ -151,5 +174,11 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
+ schedule_post_trial_emails
+ end
+
+ def schedule_post_trial_emails
+ Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early')
+ Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late')
end
end
diff --git a/app/queries/hexagon_query.rb b/app/queries/hexagon_query.rb
new file mode 100644
index 00000000..d54f4bda
--- /dev/null
+++ b/app/queries/hexagon_query.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+class HexagonQuery
+ # Maximum number of hexagons to return in a single request
+ MAX_HEXAGONS_PER_REQUEST = 5000
+
+ attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
+
+ def initialize(min_lon:, min_lat:, max_lon:, max_lat:, hex_size:, user_id: nil, start_date: nil, end_date: nil)
+ @min_lon = min_lon
+ @min_lat = min_lat
+ @max_lon = max_lon
+ @max_lat = max_lat
+ @hex_size = hex_size
+ @user_id = user_id
+ @start_date = start_date
+ @end_date = end_date
+ end
+
+ def call
+ binds = []
+ user_sql = build_user_filter(binds)
+ date_filter = build_date_filter(binds)
+
+ sql = build_hexagon_sql(user_sql, date_filter)
+
+ ActiveRecord::Base.connection.exec_query(sql, 'hexagon_sql', binds)
+ end
+
+ private
+
+ def build_hexagon_sql(user_sql, date_filter)
+ <<~SQL
+ WITH bbox_geom AS (
+ SELECT ST_MakeEnvelope($1, $2, $3, $4, 4326) as geom
+ ),
+ bbox_utm AS (
+ SELECT
+ ST_Transform(geom, 3857) as geom_utm,
+ geom as geom_wgs84
+ FROM bbox_geom
+ ),
+ user_points AS (
+ SELECT
+ lonlat::geometry as point_geom,
+ ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
+ id,
+ timestamp
+ FROM points
+ WHERE #{user_sql}
+ #{date_filter}
+ AND ST_Intersects(
+ lonlat,
+ (SELECT geom FROM bbox_geom)::geometry
+ )
+ ),
+ hex_grid AS (
+ SELECT
+ (ST_HexagonGrid($5, bbox_utm.geom_utm)).geom as hex_geom_utm,
+ (ST_HexagonGrid($5, bbox_utm.geom_utm)).i as hex_i,
+ (ST_HexagonGrid($5, bbox_utm.geom_utm)).j as hex_j
+ FROM bbox_utm
+ ),
+ hexagons_with_points AS (
+ SELECT DISTINCT
+ hex_geom_utm,
+ hex_i,
+ hex_j
+ FROM hex_grid hg
+ INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
+ ),
+ hexagon_stats AS (
+ SELECT
+ hwp.hex_geom_utm,
+ hwp.hex_i,
+ hwp.hex_j,
+ COUNT(up.id) as point_count,
+ MIN(up.timestamp) as earliest_point,
+ MAX(up.timestamp) as latest_point
+ FROM hexagons_with_points hwp
+ INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
+ GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
+ )
+ SELECT
+ ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
+ hex_i,
+ hex_j,
+ point_count,
+ earliest_point,
+ latest_point,
+ row_number() OVER (ORDER BY point_count DESC) as id
+ FROM hexagon_stats
+ ORDER BY point_count DESC
+ LIMIT $6;
+ SQL
+ end
+
+ def build_user_filter(binds)
+ # Add bbox coordinates: min_lon, min_lat, max_lon, max_lat
+ binds << min_lon
+ binds << min_lat
+ binds << max_lon
+ binds << max_lat
+
+ # Add hex_size
+ binds << hex_size
+
+ # Add limit
+ binds << MAX_HEXAGONS_PER_REQUEST
+
+ if user_id
+ binds << user_id
+ 'user_id = $7'
+ else
+ '1=1'
+ end
+ end
+
+ def build_date_filter(binds)
+ return '' unless start_date || end_date
+
+ conditions = []
+ current_param_index = user_id ? 8 : 7 # Account for bbox, hex_size, limit, and potential user_id
+
+ if start_date
+ start_timestamp = parse_date_to_timestamp(start_date)
+ binds << start_timestamp
+ conditions << "timestamp >= $#{current_param_index}"
+ current_param_index += 1
+ end
+
+ if end_date
+ end_timestamp = parse_date_to_timestamp(end_date)
+ binds << end_timestamp
+ conditions << "timestamp <= $#{current_param_index}"
+ end
+
+ conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
+ end
+
+ def parse_date_to_timestamp(date_string)
+ # Convert ISO date string to timestamp integer
+ Time.parse(date_string).to_i
+ rescue ArgumentError => e
+ ExceptionReporter.call(e, "Invalid date format: #{date_string}")
+ raise ArgumentError, "Invalid date format: #{date_string}"
+ end
+end
diff --git a/app/queries/stats_query.rb b/app/queries/stats_query.rb
index 0192a8c8..a2fe5c10 100644
--- a/app/queries/stats_query.rb
+++ b/app/queries/stats_query.rb
@@ -6,22 +6,34 @@ class StatsQuery
end
def points_stats
- sql = ActiveRecord::Base.sanitize_sql_array([
- <<~SQL.squish,
- SELECT
- COUNT(id) as total,
- COUNT(reverse_geocoded_at) as geocoded,
- COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
- FROM points
- WHERE user_id = ?
- SQL
- user.id
- ])
+ cached_stats = Rails.cache.fetch("dawarich/user_#{user.id}_points_geocoded_stats", expires_in: 1.day) do
+ cached_points_geocoded_stats
+ end
+
+ {
+ total: user.points_count,
+ geocoded: cached_stats[:geocoded],
+ without_data: cached_stats[:without_data]
+ }
+ end
+
+ def cached_points_geocoded_stats
+ sql = ActiveRecord::Base.sanitize_sql_array(
+ [
+ <<~SQL.squish,
+ SELECT
+ COUNT(reverse_geocoded_at) as geocoded,
+ COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data
+ FROM points
+ WHERE user_id = ?
+ SQL
+ user.id
+ ]
+ )
result = Point.connection.select_one(sql)
{
- total: result['total'].to_i,
geocoded: result['geocoded'].to_i,
without_data: result['without_data'].to_i
}
diff --git a/app/services/cache/clean.rb b/app/services/cache/clean.rb
index 15647b99..ecbfafed 100644
--- a/app/services/cache/clean.rb
+++ b/app/services/cache/clean.rb
@@ -7,6 +7,8 @@ class Cache::Clean
delete_control_flag
delete_version_cache
delete_years_tracked_cache
+ delete_points_geocoded_stats_cache
+ delete_countries_cities_cache
Rails.logger.info('Cache cleaned')
end
@@ -25,5 +27,18 @@ class Cache::Clean
Rails.cache.delete("dawarich/user_#{user.id}_years_tracked")
end
end
+
+ def delete_points_geocoded_stats_cache
+ User.find_each do |user|
+ Rails.cache.delete("dawarich/user_#{user.id}_points_geocoded_stats")
+ end
+ end
+
+ def delete_countries_cities_cache
+ User.find_each do |user|
+ Rails.cache.delete("dawarich/user_#{user.id}_countries")
+ Rails.cache.delete("dawarich/user_#{user.id}_cities")
+ end
+ end
end
end
diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb
index ac4b2451..333cb7ac 100644
--- a/app/services/countries_and_cities.rb
+++ b/app/services/countries_and_cities.rb
@@ -11,7 +11,7 @@ class CountriesAndCities
def call
points
.reject { |point| point.country_name.nil? || point.city.nil? }
- .group_by { |point| point.country_name }
+ .group_by(&:country_name)
.transform_values { |country_points| process_country_points(country_points) }
.map { |country, cities| CountryData.new(country: country, cities: cities) }
end
diff --git a/app/services/maps/hexagon_grid.rb b/app/services/maps/hexagon_grid.rb
new file mode 100644
index 00000000..716c78c2
--- /dev/null
+++ b/app/services/maps/hexagon_grid.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+class Maps::HexagonGrid
+ include ActiveModel::Validations
+
+ # Constants for configuration
+ DEFAULT_HEX_SIZE = 500 # meters (center to edge)
+ MAX_AREA_KM2 = 250_000 # 500km x 500km
+
+ # Validation error classes
+ class BoundingBoxTooLargeError < StandardError; end
+ class InvalidCoordinatesError < StandardError; end
+ class PostGISError < StandardError; end
+
+ attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date, :viewport_width,
+ :viewport_height
+
+ validates :min_lon, :max_lon, inclusion: { in: -180..180 }
+ validates :min_lat, :max_lat, inclusion: { in: -90..90 }
+ validates :hex_size, numericality: { greater_than: 0 }
+
+ validate :validate_bbox_order
+ validate :validate_area_size
+
+ def initialize(params = {})
+ @min_lon = params[:min_lon].to_f
+ @min_lat = params[:min_lat].to_f
+ @max_lon = params[:max_lon].to_f
+ @max_lat = params[:max_lat].to_f
+ @hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
+ @viewport_width = params[:viewport_width]&.to_f
+ @viewport_height = params[:viewport_height]&.to_f
+ @user_id = params[:user_id]
+ @start_date = params[:start_date]
+ @end_date = params[:end_date]
+ end
+
+ def call
+ validate!
+
+ generate_hexagons
+ end
+
+ def area_km2
+ @area_km2 ||= calculate_area_km2
+ end
+
+ private
+
+ def calculate_area_km2
+ width = (max_lon - min_lon).abs
+ height = (max_lat - min_lat).abs
+
+ # Convert degrees to approximate kilometers
+ # 1 degree latitude ≈ 111 km
+ # 1 degree longitude ≈ 111 km * cos(latitude)
+ avg_lat = (min_lat + max_lat) / 2
+ width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
+ height_km = height * 111
+
+ width_km * height_km
+ end
+
+ def validate_bbox_order
+ errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon
+ errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat
+ end
+
+ def validate_area_size
+ return unless area_km2 > MAX_AREA_KM2
+
+ errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²")
+ end
+
+ def generate_hexagons
+ query = HexagonQuery.new(
+ min_lon:, min_lat:, max_lon:, max_lat:,
+ hex_size:, user_id:, start_date:, end_date:
+ )
+
+ result = query.call
+
+ format_hexagons(result)
+ rescue ActiveRecord::StatementInvalid => e
+ message = "Failed to generate hexagon grid: #{e.message}"
+
+ ExceptionReporter.call(e, message)
+ raise PostGISError, message
+ end
+
+ def format_hexagons(result)
+ total_points = 0
+
+ hexagons = result.map do |row|
+ point_count = row['point_count'].to_i
+ total_points += point_count
+
+ # Parse timestamps and format dates
+ earliest = row['earliest_point'] ? Time.zone.at(row['earliest_point'].to_f).iso8601 : nil
+ latest = row['latest_point'] ? Time.zone.at(row['latest_point'].to_f).iso8601 : nil
+
+ {
+ type: 'Feature',
+ id: row['id'],
+ geometry: JSON.parse(row['geojson']),
+ properties: {
+ hex_id: row['id'],
+ hex_i: row['hex_i'],
+ hex_j: row['hex_j'],
+ hex_size: hex_size,
+ point_count: point_count,
+ earliest_point: earliest,
+ latest_point: latest
+ }
+ }
+ end
+
+ {
+ 'type' => 'FeatureCollection',
+ 'features' => hexagons,
+ 'metadata' => {
+ 'bbox' => [min_lon, min_lat, max_lon, max_lat],
+ 'area_km2' => area_km2.round(2),
+ 'hex_size_m' => hex_size,
+ 'count' => hexagons.count,
+ 'total_points' => total_points,
+ 'user_id' => user_id,
+ 'date_range' => build_date_range_metadata
+ }
+ }
+ end
+
+ def build_date_range_metadata
+ return nil unless start_date || end_date
+
+ { 'start_date' => start_date, 'end_date' => end_date }
+ end
+
+ def validate!
+ return if valid?
+
+ raise BoundingBoxTooLargeError, errors.full_messages.join(', ') if area_km2 > MAX_AREA_KM2
+
+ raise InvalidCoordinatesError, errors.full_messages.join(', ')
+ end
+
+ def viewport_valid?
+ viewport_width &&
+ viewport_height &&
+ viewport_width.positive? &&
+ viewport_height.positive?
+ end
+end
diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb
index d05bafb3..33689542 100644
--- a/app/services/stats/calculate_month.rb
+++ b/app/services/stats/calculate_month.rb
@@ -59,12 +59,13 @@ class Stats::CalculateMonth
end
def toponyms
- toponym_points = user
- .points
- .without_raw_data
- .where(timestamp: start_timestamp..end_timestamp)
- .select(:city, :country_name)
- .distinct
+ toponym_points =
+ user
+ .points
+ .without_raw_data
+ .where(timestamp: start_timestamp..end_timestamp)
+ .select(:city, :country_name)
+ .distinct
CountriesAndCities.new(toponym_points).call
end
diff --git a/app/services/tracks/generator.rb b/app/services/tracks/generator.rb
deleted file mode 100644
index 0510a4e5..00000000
--- a/app/services/tracks/generator.rb
+++ /dev/null
@@ -1,215 +0,0 @@
-# frozen_string_literal: true
-
-# This service handles both bulk and incremental track generation using a unified
-# approach with different modes:
-#
-# - :bulk - Regenerates all tracks from scratch (replaces existing)
-# - :incremental - Processes untracked points up to a specified end time
-# - :daily - Processes tracks on a daily basis
-#
-# Key features:
-# - Deterministic results (same algorithm for all modes)
-# - Simple incremental processing without buffering complexity
-# - Configurable time and distance thresholds from user settings
-# - Automatic track statistics calculation
-# - Proper handling of edge cases (empty points, incomplete segments)
-#
-# Usage:
-# # Bulk regeneration
-# Tracks::Generator.new(user, mode: :bulk).call
-#
-# # Incremental processing
-# Tracks::Generator.new(user, mode: :incremental).call
-#
-# # Daily processing
-# Tracks::Generator.new(user, start_at: Date.current, mode: :daily).call
-#
-class Tracks::Generator
- include Tracks::Segmentation
- include Tracks::TrackBuilder
-
- attr_reader :user, :start_at, :end_at, :mode
-
- def initialize(user, start_at: nil, end_at: nil, mode: :bulk)
- @user = user
- @start_at = start_at
- @end_at = end_at
- @mode = mode.to_sym
- end
-
- def call
- clean_existing_tracks if should_clean_tracks?
-
- start_timestamp, end_timestamp = get_timestamp_range
-
- segments = Track.get_segments_with_points(
- user.id,
- start_timestamp,
- end_timestamp,
- time_threshold_minutes,
- distance_threshold_meters,
- untracked_only: mode == :incremental
- )
-
- tracks_created = 0
-
- segments.each do |segment|
- track = create_track_from_segment(segment)
- tracks_created += 1 if track
- end
-
- tracks_created
- end
-
- private
-
- def should_clean_tracks?
- case mode
- when :bulk, :daily then true
- else false
- end
- end
-
- def load_points
- case mode
- when :bulk then load_bulk_points
- when :incremental then load_incremental_points
- when :daily then load_daily_points
- else
- raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
- end
- end
-
- def load_bulk_points
- scope = user.points.order(:timestamp)
- scope = scope.where(timestamp: timestamp_range) if time_range_defined?
-
- scope
- end
-
- def load_incremental_points
- # For incremental mode, we process untracked points
- # If end_at is specified, only process points up to that time
- scope = user.points.where(track_id: nil).order(:timestamp)
- scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
-
- scope
- end
-
- def load_daily_points
- day_range = daily_time_range
-
- user.points.where(timestamp: day_range).order(:timestamp)
- end
-
- def create_track_from_segment(segment_data)
- points = segment_data[:points]
- pre_calculated_distance = segment_data[:pre_calculated_distance]
-
- return unless points.size >= 2
-
- create_track_from_points(points, pre_calculated_distance)
- end
-
- def time_range_defined?
- start_at.present? || end_at.present?
- end
-
- def time_range
- return nil unless time_range_defined?
-
- start_time = start_at&.to_i
- end_time = end_at&.to_i
-
- if start_time && end_time
- Time.zone.at(start_time)..Time.zone.at(end_time)
- elsif start_time
- Time.zone.at(start_time)..
- elsif end_time
- ..Time.zone.at(end_time)
- end
- end
-
- def timestamp_range
- return nil unless time_range_defined?
-
- start_time = start_at&.to_i
- end_time = end_at&.to_i
-
- if start_time && end_time
- start_time..end_time
- elsif start_time
- start_time..
- elsif end_time
- ..end_time
- end
- end
-
- def daily_time_range
- day = start_at&.to_date || Date.current
- day.beginning_of_day.to_i..day.end_of_day.to_i
- end
-
- def clean_existing_tracks
- case mode
- when :bulk then clean_bulk_tracks
- when :daily then clean_daily_tracks
- else
- raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
- end
- end
-
- def clean_bulk_tracks
- scope = user.tracks
- scope = scope.where(start_at: time_range) if time_range_defined?
-
- scope.destroy_all
- end
-
- def clean_daily_tracks
- day_range = daily_time_range
- range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
-
- scope = user.tracks.where(start_at: range)
- scope.destroy_all
- end
-
- def get_timestamp_range
- case mode
- when :bulk then bulk_timestamp_range
- when :daily then daily_timestamp_range
- when :incremental then incremental_timestamp_range
- else
- raise ArgumentError, "Tracks::Generator: Unknown mode: #{mode}"
- end
- end
-
- def bulk_timestamp_range
- return [start_at.to_i, end_at.to_i] if start_at && end_at
-
- first_point = user.points.order(:timestamp).first
- last_point = user.points.order(:timestamp).last
-
- [first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
- end
-
- def daily_timestamp_range
- day = start_at&.to_date || Date.current
- [day.beginning_of_day.to_i, day.end_of_day.to_i]
- end
-
- def incremental_timestamp_range
- first_point = user.points.where(track_id: nil).order(:timestamp).first
- end_timestamp = end_at ? end_at.to_i : Time.current.to_i
-
- [first_point&.timestamp || 0, end_timestamp]
- end
-
- def distance_threshold_meters
- @distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
- end
-
- def time_threshold_minutes
- @time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
- end
-end
diff --git a/app/services/tracks/incremental_processor.rb b/app/services/tracks/incremental_processor.rb
deleted file mode 100644
index f02305a8..00000000
--- a/app/services/tracks/incremental_processor.rb
+++ /dev/null
@@ -1,92 +0,0 @@
-# frozen_string_literal: true
-
-# This service analyzes new points as they're created and determines whether
-# they should trigger incremental track generation based on time and distance
-# thresholds defined in user settings.
-#
-# The key insight is that we should trigger track generation when there's a
-# significant gap between the new point and the previous point, indicating
-# the end of a journey and the start of a new one.
-#
-# Process:
-# 1. Check if the new point should trigger processing (skip imported points)
-# 2. Find the last point before the new point
-# 3. Calculate time and distance differences
-# 4. If thresholds are exceeded, trigger incremental generation
-# 5. Set the end_at time to the previous point's timestamp for track finalization
-#
-# This ensures tracks are properly finalized when journeys end, not when they start.
-#
-# Usage:
-# # In Point model after_create_commit callback
-# Tracks::IncrementalProcessor.new(user, new_point).call
-#
-class Tracks::IncrementalProcessor
- attr_reader :user, :new_point, :previous_point
-
- def initialize(user, new_point)
- @user = user
- @new_point = new_point
- @previous_point = find_previous_point
- end
-
- def call
- return unless should_process?
-
- start_at = find_start_time
- end_at = find_end_time
-
- Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental)
- end
-
- private
-
- def should_process?
- return false if new_point.import_id.present?
- return true unless previous_point
-
- exceeds_thresholds?(previous_point, new_point)
- end
-
- def find_previous_point
- @previous_point ||=
- user.points
- .where('timestamp < ?', new_point.timestamp)
- .order(:timestamp)
- .last
- end
-
- def find_start_time
- user.tracks.order(:end_at).last&.end_at
- end
-
- def find_end_time
- previous_point ? Time.zone.at(previous_point.timestamp) : nil
- end
-
- def exceeds_thresholds?(previous_point, current_point)
- time_gap = time_difference_minutes(previous_point, current_point)
- distance_gap = distance_difference_meters(previous_point, current_point)
-
- time_exceeded = time_gap >= time_threshold_minutes
- distance_exceeded = distance_gap >= distance_threshold_meters
-
- time_exceeded || distance_exceeded
- end
-
- def time_difference_minutes(point1, point2)
- (point2.timestamp - point1.timestamp) / 60.0
- end
-
- def distance_difference_meters(point1, point2)
- point1.distance_to(point2) * 1000
- end
-
- def time_threshold_minutes
- @time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i
- end
-
- def distance_threshold_meters
- @distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i
- end
-end
diff --git a/app/services/tracks/parallel_generator.rb b/app/services/tracks/parallel_generator.rb
index 305adc8c..ea8c8ac2 100644
--- a/app/services/tracks/parallel_generator.rb
+++ b/app/services/tracks/parallel_generator.rb
@@ -17,8 +17,7 @@ class Tracks::ParallelGenerator
end
def call
- # Clean existing tracks if needed
- clean_existing_tracks if should_clean_tracks?
+ clean_bulk_tracks if mode == :bulk
# Generate time chunks
time_chunks = generate_time_chunks
@@ -40,13 +39,6 @@ class Tracks::ParallelGenerator
private
- def should_clean_tracks?
- case mode
- when :bulk, :daily then true
- else false
- end
- end
-
def generate_time_chunks
chunker = Tracks::TimeChunker.new(
user,
@@ -95,30 +87,18 @@ class Tracks::ParallelGenerator
)
end
- def clean_existing_tracks
- case mode
- when :bulk then clean_bulk_tracks
- when :daily then clean_daily_tracks
- else
- raise ArgumentError, "Unknown mode: #{mode}"
- end
- end
-
def clean_bulk_tracks
if time_range_defined?
- user.tracks.where(start_at: time_range).destroy_all
+ user.tracks.where(
+ '(start_at, end_at) OVERLAPS (?, ?)',
+ start_at&.in_time_zone,
+ end_at&.in_time_zone
+ ).destroy_all
else
user.tracks.destroy_all
end
end
- def clean_daily_tracks
- day_range = daily_time_range
- range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end)
-
- user.tracks.where(start_at: range).destroy_all
- end
-
def time_range_defined?
start_at.present? || end_at.present?
end
@@ -162,8 +142,8 @@ class Tracks::ParallelGenerator
else
# Convert seconds to readable format
seconds = duration.to_i
- if seconds >= 86400 # days
- days = seconds / 86400
+ if seconds >= 86_400 # days
+ days = seconds / 86_400
"#{days} day#{'s' if days != 1}"
elsif seconds >= 3600 # hours
hours = seconds / 3600
diff --git a/app/services/tracks/segmentation.rb b/app/services/tracks/segmentation.rb
index 3dd8e853..cbc5b471 100644
--- a/app/services/tracks/segmentation.rb
+++ b/app/services/tracks/segmentation.rb
@@ -21,8 +21,8 @@
# time_threshold_minutes methods.
#
# Used by:
-# - Tracks::Generator for splitting points during track generation
-# - Tracks::CreateFromPoints for legacy compatibility
+# - Tracks::ParallelGenerator and related jobs for splitting points during parallel track generation
+# - Tracks::BoundaryDetector for cross-chunk track merging
#
# Example usage:
# class MyTrackProcessor
diff --git a/app/services/tracks/session_manager.rb b/app/services/tracks/session_manager.rb
index 9a0280de..99ad322f 100644
--- a/app/services/tracks/session_manager.rb
+++ b/app/services/tracks/session_manager.rb
@@ -27,6 +27,12 @@ class Tracks::SessionManager
}
Rails.cache.write(cache_key, session_data, expires_in: DEFAULT_TTL)
+ # Initialize counters atomically using Redis SET
+ Rails.cache.redis.with do |redis|
+ redis.set(counter_key('completed_chunks'), 0, ex: DEFAULT_TTL.to_i)
+ redis.set(counter_key('tracks_created'), 0, ex: DEFAULT_TTL.to_i)
+ end
+
self
end
@@ -44,8 +50,10 @@ class Tracks::SessionManager
def get_session_data
data = Rails.cache.read(cache_key)
return nil unless data
-
- # Rails.cache already deserializes the data, no need for JSON parsing
+
+ # Include current counter values
+ data['completed_chunks'] = counter_value('completed_chunks')
+ data['tracks_created'] = counter_value('tracks_created')
data
end
@@ -65,20 +73,18 @@ class Tracks::SessionManager
# Increment completed chunks
def increment_completed_chunks
- session_data = get_session_data
- return false unless session_data
+ return false unless session_exists?
- new_completed = session_data['completed_chunks'] + 1
- update_session(completed_chunks: new_completed)
+ atomic_increment(counter_key('completed_chunks'), 1)
+ true
end
# Increment tracks created
def increment_tracks_created(count = 1)
- session_data = get_session_data
- return false unless session_data
+ return false unless session_exists?
- new_count = session_data['tracks_created'] + count
- update_session(tracks_created: new_count)
+ atomic_increment(counter_key('tracks_created'), count)
+ true
end
# Mark session as completed
@@ -103,7 +109,8 @@ class Tracks::SessionManager
session_data = get_session_data
return false unless session_data
- session_data['completed_chunks'] >= session_data['total_chunks']
+ completed_chunks = counter_value('completed_chunks')
+ completed_chunks >= session_data['total_chunks']
end
# Get progress percentage
@@ -114,13 +121,16 @@ class Tracks::SessionManager
total = session_data['total_chunks']
return 100 if total.zero?
- completed = session_data['completed_chunks']
+ completed = counter_value('completed_chunks')
(completed.to_f / total * 100).round(2)
end
# Delete session
def cleanup_session
Rails.cache.delete(cache_key)
+ Rails.cache.redis.with do |redis|
+ redis.del(counter_key('completed_chunks'), counter_key('tracks_created'))
+ end
end
# Class methods for session management
@@ -149,4 +159,20 @@ class Tracks::SessionManager
def cache_key
"#{CACHE_KEY_PREFIX}:user:#{user_id}:session:#{session_id}"
end
-end
\ No newline at end of file
+
+ def counter_key(field)
+ "#{cache_key}:#{field}"
+ end
+
+ def counter_value(field)
+ Rails.cache.redis.with do |redis|
+ (redis.get(counter_key(field)) || 0).to_i
+ end
+ end
+
+ def atomic_increment(key, amount)
+ Rails.cache.redis.with do |redis|
+ redis.incrby(key, amount)
+ end
+ end
+end
diff --git a/app/services/tracks/track_builder.rb b/app/services/tracks/track_builder.rb
index a988f3bf..2172b762 100644
--- a/app/services/tracks/track_builder.rb
+++ b/app/services/tracks/track_builder.rb
@@ -25,7 +25,7 @@
# This ensures consistency when users change their distance unit preferences.
#
# Used by:
-# - Tracks::Generator for creating tracks during generation
+# - Tracks::ParallelGenerator and related jobs for creating tracks during parallel generation
# - Any class that needs to convert point arrays to Track records
#
# Example usage:
@@ -60,7 +60,7 @@ module Tracks::TrackBuilder
)
# TODO: Move trips attrs to columns with more precision and range
- track.distance = [[pre_calculated_distance.round, 999999.99].min, 0].max
+ track.distance = [[pre_calculated_distance.round, 999_999].min, 0].max
track.duration = calculate_duration(points)
track.avg_speed = calculate_average_speed(track.distance, track.duration)
@@ -103,7 +103,7 @@ module Tracks::TrackBuilder
speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h
# Cap the speed to prevent database precision overflow (max 999999.99)
- [speed_kmh, 999999.99].min
+ [speed_kmh, 999_999.99].min
end
def calculate_elevation_stats(points)
@@ -145,6 +145,6 @@ module Tracks::TrackBuilder
private
def user
- raise NotImplementedError, "Including class must implement user method"
+ raise NotImplementedError, 'Including class must implement user method'
end
end
diff --git a/app/services/users/import_data/stats.rb b/app/services/users/import_data/stats.rb
index f6540c1c..c11ead0a 100644
--- a/app/services/users/import_data/stats.rb
+++ b/app/services/users/import_data/stats.rb
@@ -60,11 +60,12 @@ class Users::ImportData::Stats
end
def prepare_stat_attributes(stat_data)
- attributes = stat_data.except('created_at', 'updated_at')
+ attributes = stat_data.except('created_at', 'updated_at', 'sharing_uuid')
attributes['user_id'] = user.id
attributes['created_at'] = Time.current
attributes['updated_at'] = Time.current
+ attributes['sharing_uuid'] = SecureRandom.uuid
attributes.symbolize_keys
rescue StandardError => e
diff --git a/app/views/devise/registrations/_api_key.html.erb b/app/views/devise/registrations/_api_key.html.erb
index aeba5bfd..37daa7fd 100644
--- a/app/views/devise/registrations/_api_key.html.erb
+++ b/app/views/devise/registrations/_api_key.html.erb
@@ -2,12 +2,10 @@
Use this API key to authenticate your requests.
<%= current_user.api_key %>
- <% if ENV['QR_CODE_ENABLED'] == 'true' %>
-
- Or you can scan it in your Dawarich iOS app:
- <%= api_key_qr_code(current_user) %>
-
- <% end %>
+
+ Or you can scan it in your Dawarich iOS app:
+ <%= api_key_qr_code(current_user) %>
+
Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %>
diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb
index cf7ac463..c00c405f 100644
--- a/app/views/shared/_navbar.html.erb
+++ b/app/views/shared/_navbar.html.erb
@@ -87,19 +87,8 @@
-
-
-
-
+
+ <%= icon 'bell' %>
<% if @unread_notifications.present? %>
<%= @unread_notifications.size %>
@@ -127,7 +116,9 @@
<% end %>
<% if current_user.admin? %>
- ⭐️
+
+ <%= icon 'star' %>
+
<% end %>
diff --git a/app/views/shared/_sharing_modal.html.erb b/app/views/shared/_sharing_modal.html.erb
new file mode 100644
index 00000000..beb120d0
--- /dev/null
+++ b/app/views/shared/_sharing_modal.html.erb
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+ <%= icon 'link' %> Sharing Settings
+
+
+
+
+
+
+
+
+
+
+
+
+ Link expiration
+
+
+ <%= options_for_select([
+ ['1 hour', '1h'],
+ ['12 hours', '12h'],
+ ['24 hours', '24h'],
+ ['Permanent', 'permanent']
+ ], @stat&.sharing_settings&.dig('expiration') || '1h') %>
+
+
+
+
+
+
+
+
+
+
+ <%= icon 'info' %>
+
+
Privacy Protection
+
+ • Exact coordinates are hidden
+ • Personal information is not included
+
+
+
+
+
+
+
+ Done
+
+
+
+
+
diff --git a/app/views/stats/_month.html.erb b/app/views/stats/_month.html.erb
new file mode 100644
index 00000000..1c16abf5
--- /dev/null
+++ b/app/views/stats/_month.html.erb
@@ -0,0 +1,320 @@
+
+
+
+
+
+
+ <%= "#{icon month_icon(stat)} #{Date::MONTHNAMES[month]} #{year}".html_safe %>
+
+
Monthly Digest
+
+ <%= icon 'share' %> Share
+
+
+
+
+
+
+
+
+ <%= icon 'map-plus' %> Distance traveled
+
+
~<%= distance_traveled(current_user, stat) %>
+
<%= x_than_average_distance(stat, @average_distance_this_year) %>
+
+
+
+
+ <%= icon 'calendar-check-2' %> Active days
+
+
+ <%= active_days(stat) %>
+
+
+ <%= x_than_previous_active_days(stat, previous_stat) %>
+
+
+
+
+
+ <%= icon 'map-pin-plus' %> Countries visited
+
+
+ <%= countries_visited(stat) %>
+
+
+ <%= x_than_previous_countries_visited(stat, previous_stat) %>
+
+
+
+
+
+
+
+
+
+ <%= icon 'map' %>
+ Map Summary
+
+
+
+ <%= icon 'flame' %> Heatmap
+
+
+ <%= icon 'map-pin' %> Points
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%= icon 'activity' %> Daily Activity
+
+
+ <%= column_chart(
+ stat.daily_distance.map { |day, distance_meters|
+ [day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
+ },
+ height: '200px',
+ suffix: " #{current_user.safe_settings.distance_unit}",
+ xtitle: 'Day',
+ ytitle: 'Distance',
+ colors: [
+ '#570df8', '#f000b8', '#ffea00',
+ '#00d084', '#3abff8', '#ff5724',
+ '#8e24aa', '#3949ab', '#00897b',
+ '#d81b60', '#5e35b1', '#039be5',
+ '#43a047', '#f4511e', '#6d4c41',
+ '#757575', '#546e7a', '#d32f2f'
+ ],
+ library: {
+ plugins: {
+ legend: { display: false }
+ },
+ scales: {
+ x: {
+ grid: { color: 'rgba(0,0,0,0.1)' }
+ },
+ y: {
+ grid: { color: 'rgba(0,0,0,0.1)' }
+ }
+ }
+ }
+ ) %>
+
+
+ Peak day: <%= peak_day(stat) %> • Quietest week: <%= quietest_week(stat) %>
+
+
+
+
+
+
+
+
+
+
+
+ <%= icon 'globe' %> Countries & Cities
+
+
+ <% if stat.toponyms.present? %>
+ <% max_cities = stat.toponyms.map { |country| country['cities'].length }.max %>
+ <% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %>
+
+ <% stat.toponyms.each_with_index do |country, index| %>
+ <% cities_count = country['cities'].length %>
+ <% progress_value = max_cities > 0 ? (cities_count.to_f / max_cities * 100).round : 0 %>
+ <% color_class = progress_colors[index % progress_colors.length] %>
+
+
+
+ <%= country['country'] %>
+
+ <%= pluralize(cities_count, 'city') %>
+ <% if progress_value > 0 %>
+ (<%= progress_value %>%)
+ <% end %>
+
+
+
+
+ <% end %>
+ <% else %>
+
+
No location data available for this month
+
+ <% end %>
+
+
+
+
+
+
Cities visited:
+ <% stat.toponyms.each do |country| %>
+ <% country['cities'].each do |city| %>
+
<%= city['city'] %>
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+<%= render 'shared/sharing_modal' %>
diff --git a/app/views/stats/_stat.html.erb b/app/views/stats/_stat.html.erb
index 14430331..0f2eda1f 100644
--- a/app/views/stats/_stat.html.erb
+++ b/app/views/stats/_stat.html.erb
@@ -1,30 +1,32 @@
-
-
-
<%= Date::MONTHNAMES[stat.month] %> <%= stat.year %>
+<%= link_to "#{stat.year}/#{stat.month}",
+ class: "group block p-6 bg-base-100 hover:bg-base-200/50 rounded-xl border border-base-300 hover:border-primary/40 hover:shadow-lg transition-all duration-200 hover:scale-[1.02]" do %>
-
- <%= link_to "Details", points_path(year: stat.year, month: stat.month),
- class: "link link-primary" %>
+
+
+
+ <%= "#{icon month_icon(stat)} #{Date::MONTHNAMES[stat.month]} #{stat.year}".html_safe %>
+
+
-
-
-
<%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %>
+
+
+
+
+
+ <%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %>
+ <%= current_user.safe_settings.distance_unit %>
+
+
Total distance
+
+
+
+
+ <%= countries_and_cities_stat_for_month(stat) %>
-
-
- <%= countries_and_cities_stat_for_month(stat) %>
-
-
- <%= area_chart(
- stat.daily_distance.map { |day, distance_meters|
- [day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
- },
- height: '200px',
- suffix: " #{current_user.safe_settings.distance_unit}",
- xtitle: 'Day',
- ytitle: 'Distance'
- ) %>
-
+<% end %>
diff --git a/app/views/stats/_year.html.erb b/app/views/stats/_year.html.erb
index fc5fd1e6..e2168ef9 100644
--- a/app/views/stats/_year.html.erb
+++ b/app/views/stats/_year.html.erb
@@ -10,7 +10,13 @@
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
- ytitle: 'Distance'
+ ytitle: 'Distance',
+ colors: [
+ '#397bb5', '#5A4E9D', '#3B945E',
+ '#7BC96F', '#FFD54F', '#FFA94D',
+ '#FF6B6B', '#FF8C42', '#C97E4F',
+ '#8B4513', '#5A2E2E', '#265d7d'
+ ]
) %>
diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb
index bd06de8e..926b5b5e 100644
--- a/app/views/stats/index.html.erb
+++ b/app/views/stats/index.html.erb
@@ -1,7 +1,7 @@
<% content_for :title, 'Statistics' %>
-
+
<%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %>
@@ -21,6 +21,10 @@
<% end %>
+
+ All stats data above except for total distance and number of geopoints tracked is being updated daily
+
+
<% if current_user.active? %>
<%= link_to 'Update stats', update_all_stats_path, data: { turbo_method: :put }, class: 'btn btn-primary mt-5' %>
<% end %>
@@ -34,15 +38,13 @@
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
-
+
Last update: <%= human_date(stats.first.updated_at) %>
- <%= link_to '🔄', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
+ <%= link_to icon('refresh-ccw'), update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:text-primary' %>
- <% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
- <%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %>
- <% end %>
+ <%= number_with_delimiter year_distance_stat(@year_distances[year], current_user).round %> <%= current_user.safe_settings.distance_unit %>
<% if DawarichSettings.reverse_geocoding_enabled? %>
@@ -90,7 +92,13 @@
height: '200px',
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
- ytitle: 'Distance'
+ ytitle: 'Distance',
+ colors: [
+ '#397bb5', '#5A4E9D', '#3B945E',
+ '#7BC96F', '#FFD54F', '#FFA94D',
+ '#FF6B6B', '#FF8C42', '#C97E4F',
+ '#8B4513', '#5A2E2E', '#265d7d'
+ ]
) %>
diff --git a/app/views/stats/month.html.erb b/app/views/stats/month.html.erb
new file mode 100644
index 00000000..72d96b80
--- /dev/null
+++ b/app/views/stats/month.html.erb
@@ -0,0 +1,5 @@
+<% content_for :title, "#{Date::MONTHNAMES[@month]} #{@year} Monthly Digest" %>
+
+
+ <%= render partial: 'stats/month', locals: { year: @year, month: @month, stat: @stat, previous_stat: @previous_stat } %>
+
diff --git a/app/views/stats/public_month.html.erb b/app/views/stats/public_month.html.erb
new file mode 100644
index 00000000..dec15c15
--- /dev/null
+++ b/app/views/stats/public_month.html.erb
@@ -0,0 +1,185 @@
+
+
+
+
+
+
Shared Stats - <%= Date::MONTHNAMES[@month] %> <%= @year %>
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+ <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
+ <%= javascript_importmap_tags %>
+
+
+
+
+
+
+
+ <% if @self_hosted %>
+
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+ <%= "#{icon month_icon(@stat)} #{Date::MONTHNAMES[@month]} #{@year}".html_safe %>
+
+
Monthly Digest
+
+
+
+
+
+
+
Distance traveled
+
<%= distance_traveled(@user, @stat) %>
+
Total distance for this month
+
+
+
+
Active days
+
+ <%= active_days(@stat) %>
+
+
+ Days with tracked activity
+
+
+
+
+
Countries visited
+
+ <%= countries_visited(@stat) %>
+
+
+ Different countries
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading hexagons...
+
+
+
+
+
+
+
+
+
+
+ <%= icon 'trending-up' %> Daily Activity
+
+
+ <%= column_chart(
+ @stat.daily_distance.map { |day, distance_meters|
+ [day, Stat.convert_distance(distance_meters, 'km').round]
+ },
+ height: '200px',
+ suffix: " km",
+ xtitle: 'Day',
+ ytitle: 'Distance',
+ colors: [
+ '#570df8', '#f000b8', '#ffea00',
+ '#00d084', '#3abff8', '#ff5724',
+ '#8e24aa', '#3949ab', '#00897b',
+ '#d81b60', '#5e35b1', '#039be5',
+ '#43a047', '#f4511e', '#6d4c41',
+ '#757575', '#546e7a', '#d32f2f'
+ ],
+ library: {
+ plugins: {
+ legend: { display: false }
+ },
+ scales: {
+ x: {
+ grid: { color: 'rgba(0,0,0,0.1)' }
+ },
+ y: {
+ grid: { color: 'rgba(0,0,0,0.1)' }
+ }
+ }
+ }
+ ) %>
+
+
+ Peak day: <%= peak_day(@stat) %> • Quietest week: <%= quietest_week(@stat) %>
+
+
+
+
+
+
+
+
+ <%= icon 'earth' %> Countries & Cities
+
+
+ <% @stat.toponyms.each_with_index do |country, index| %>
+
+
+ <%= country['country'] %>
+ <%= country['cities'].length %> cities
+
+
+
+ <% end %>
+
+
+
+
+
+
Cities visited:
+ <% @stat.toponyms.each do |country| %>
+ <% country['cities'].first(5).each do |city| %>
+
<%= city['city'] %>
+ <% end %>
+ <% if country['cities'].length > 5 %>
+
+<%= country['cities'].length - 5 %> more
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+
+ Powered by
Dawarich , your personal memories mapper.
+
+
+
+
+
+
+
+
diff --git a/app/views/users_mailer/explore_features.html.erb b/app/views/users_mailer/explore_features.html.erb
index 9d8c64c0..9e70e509 100644
--- a/app/views/users_mailer/explore_features.html.erb
+++ b/app/views/users_mailer/explore_features.html.erb
@@ -17,12 +17,17 @@
Explore Dawarich Features
-
Hi <%= @user.email %>,
+
Hi <%= @user.email %>, this is Evgenii from Dawarich.
-
You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.
+
You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.
Here are some powerful features you might want to explore:
+
+
✈️ Reliving your travels
+
Revisit your past journeys with detailed maps and insights.
+
+
📊 Statistics & Analytics
View detailed insights about distances traveled and time spent in different locations.
diff --git a/app/views/users_mailer/explore_features.text.erb b/app/views/users_mailer/explore_features.text.erb
index 0ffa8e99..e6b92042 100644
--- a/app/views/users_mailer/explore_features.text.erb
+++ b/app/views/users_mailer/explore_features.text.erb
@@ -1,11 +1,14 @@
Explore Dawarich Features
-Hi <%= @user.email %>,
+Hi <%= @user.email %>, this is Evgenii from Dawarich.
-You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.
+You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.
Here are some powerful features you might want to explore:
+✈️ Reliving your travels
+Revisit your past journeys with detailed maps and insights.
+
📊 Statistics & Analytics
View detailed insights about distances traveled and time spent in different locations.
diff --git a/app/views/users_mailer/post_trial_reminder_early.html.erb b/app/views/users_mailer/post_trial_reminder_early.html.erb
new file mode 100644
index 00000000..c2459943
--- /dev/null
+++ b/app/views/users_mailer/post_trial_reminder_early.html.erb
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
Hi <%= @user.email %>, this is Evgenii from Dawarich.
+
+
+
Your Dawarich trial ended 2 days ago.
+
+
+
I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!
+
+
Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.
+
+
🌟 What you're missing:
+
+ Real-time location tracking and analysis
+ Beautiful, interactive maps with your travel history
+ Detailed statistics and insights about your journeys
+ Data export capabilities for your peace of mind
+
+
+
Subscribe Now
+
+
Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!
+
+
Questions? Just reply to this email – I'm here to help.
+
+
Best regards,
+ Evgenii from Dawarich
+
+
+
+
diff --git a/app/views/users_mailer/post_trial_reminder_early.text.erb b/app/views/users_mailer/post_trial_reminder_early.text.erb
new file mode 100644
index 00000000..be2edcc8
--- /dev/null
+++ b/app/views/users_mailer/post_trial_reminder_early.text.erb
@@ -0,0 +1,24 @@
+🚀 Still Interested in Dawarich?
+
+Hi <%= @user.email %>,
+
+Your Dawarich trial ended 2 days ago.
+
+I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!
+
+Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.
+
+🌟 What you're missing:
+- Real-time location tracking and analysis
+- Beautiful, interactive maps with your travel history
+- Detailed statistics and insights about your journeys
+- Data export capabilities for your peace of mind
+
+Subscribe now: https://my.dawarich.app
+
+Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!
+
+Questions? Just reply to this email – I'm here to help.
+
+Best regards,
+Evgenii from Dawarich
diff --git a/app/views/users_mailer/post_trial_reminder_late.html.erb b/app/views/users_mailer/post_trial_reminder_late.html.erb
new file mode 100644
index 00000000..f347ecb0
--- /dev/null
+++ b/app/views/users_mailer/post_trial_reminder_late.html.erb
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
Hi <%= @user.email %>, this is Evgenii from Dawarich.
+
+
+
It's been a week since your Dawarich trial ended.
+
+
+
Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.
+
+
🗺️ Here's what's waiting for you:
+
+ All your location data, preserved and ready
+ Reliving your travels through detailed maps and insights
+ Privacy-first approach – your data stays yours
+ Beautiful visualizations of your travel patterns
+ Regular updates and new features
+
+
+
Return to Dawarich
+
+
This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.
+
+
Thank you for giving Dawarich a try. I hope to see you again soon!
+
+
Safe travels,
+ Evgenii from Dawarich
+
+
P.S. If you have any questions or need assistance, just hit reply – I'm here to help!
+
+
+
+
diff --git a/app/views/users_mailer/post_trial_reminder_late.text.erb b/app/views/users_mailer/post_trial_reminder_late.text.erb
new file mode 100644
index 00000000..d43db950
--- /dev/null
+++ b/app/views/users_mailer/post_trial_reminder_late.text.erb
@@ -0,0 +1,26 @@
+📍 Your Location Data is Waiting
+
+Hi <%= @user.email %>, this is Evgenii from Dawarich.
+
+It's been a week since your Dawarich trial ended.
+
+Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.
+
+🗺️ Here's what's waiting for you:
+- All your location data, preserved and ready
+- Reliving your travels through detailed maps and insights
+- Privacy-first approach – your data stays yours
+- Beautiful visualizations of your travel patterns
+- Integration with popular location apps and services
+- Regular updates and new features
+
+Return to Dawarich: https://my.dawarich.app
+
+This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.
+
+Thank you for giving Dawarich a try. I hope to see you again soon!
+
+Safe travels,
+Evgenii from Dawarich
+
+P.S. If you have any questions or need assistance, just hit reply – I'm here to help!
diff --git a/app/views/users_mailer/trial_expired.html.erb b/app/views/users_mailer/trial_expired.html.erb
index 3294b88b..6407fdbf 100644
--- a/app/views/users_mailer/trial_expired.html.erb
+++ b/app/views/users_mailer/trial_expired.html.erb
@@ -17,13 +17,13 @@
🔒 Your Trial Has Expired
-
Hi <%= @user.email %>,
+
Hi <%= @user.email %>, this is Evgenii from Dawarich.
Your 7-day Dawarich trial has ended.
-
Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.
+
Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.
Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.
@@ -40,7 +40,7 @@
Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!
-
We'd love to have you back as a subscriber.
+
I'd love to have you back as a subscriber.
Best regards,
Evgenii from Dawarich
diff --git a/app/views/users_mailer/trial_expired.text.erb b/app/views/users_mailer/trial_expired.text.erb
index d43178f3..338f0899 100644
--- a/app/views/users_mailer/trial_expired.text.erb
+++ b/app/views/users_mailer/trial_expired.text.erb
@@ -1,10 +1,10 @@
🔒 Your Trial Has Expired
-Hi <%= @user.email %>,
+Hi <%= @user.email %>, this is Evgenii from Dawarich.
Your 7-day Dawarich trial has ended.
-Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.
+Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.
Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.
@@ -19,7 +19,7 @@ Subscribe to continue: https://my.dawarich.app
Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!
-We'd love to have you back as a subscriber.
+I'd love to have you back as a subscriber.
Best regards,
Evgenii from Dawarich
diff --git a/app/views/users_mailer/trial_expires_soon.html.erb b/app/views/users_mailer/trial_expires_soon.html.erb
index c1e5ff6e..a0ac2f3f 100644
--- a/app/views/users_mailer/trial_expires_soon.html.erb
+++ b/app/views/users_mailer/trial_expires_soon.html.erb
@@ -17,13 +17,13 @@
⏰ Your Trial Expires Soon
-
Hi <%= @user.email %>,
+
Hi <%= @user.email %>, this is Evgenii from Dawarich.
⚠️ Important: Your Dawarich trial expires in just 2 days !
-
We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
+
I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.
@@ -40,7 +40,7 @@
Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!
-
Questions? Drop us a message at hi@dawarich.app or just reply to this email.
+
Questions? Drop me a message at hi@dawarich.app or just reply to this email.
Best regards,
Evgenii from Dawarich
diff --git a/app/views/users_mailer/trial_expires_soon.text.erb b/app/views/users_mailer/trial_expires_soon.text.erb
index c5f7352e..6b15ef3a 100644
--- a/app/views/users_mailer/trial_expires_soon.text.erb
+++ b/app/views/users_mailer/trial_expires_soon.text.erb
@@ -1,10 +1,10 @@
⏰ Your Trial Expires Soon
-Hi <%= @user.email %>,
+Hi <%= @user.email %>, this is Evgenii from Dawarich.
⚠️ Important: Your Dawarich trial expires in just 2 days!
-We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
+I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.
@@ -19,7 +19,7 @@ Subscribe now: https://my.dawarich.app
Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!
-Questions? Drop us a message at hi@dawarich.app
+Questions? Drop me a message at hi@dawarich.app or just reply to this email.
Best regards,
Evgenii from Dawarich
diff --git a/app/views/users_mailer/welcome.html.erb b/app/views/users_mailer/welcome.html.erb
index 07f80721..c3c34c82 100644
--- a/app/views/users_mailer/welcome.html.erb
+++ b/app/views/users_mailer/welcome.html.erb
@@ -16,9 +16,9 @@
Welcome to Dawarich!
-
Hi <%= @user.email %>,
+
Hi <%= @user.email %>, this is Evgenii from Dawarich.
-
Welcome to Dawarich! We're excited to have you on board.
+
Welcome to Dawarich! I'm excited to have you on board.
Your 7-day free trial has started. During this time, you can:
@@ -30,7 +30,7 @@
Start Exploring Dawarich
- If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.
+ If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.
Happy tracking!
Evgenii from Dawarich
diff --git a/app/views/users_mailer/welcome.text.erb b/app/views/users_mailer/welcome.text.erb
index 8cbf42d2..5870f372 100644
--- a/app/views/users_mailer/welcome.text.erb
+++ b/app/views/users_mailer/welcome.text.erb
@@ -1,8 +1,8 @@
Welcome to Dawarich!
-Hi <%= @user.email %>,
+Hi <%= @user.email %>, this is Evgenii from Dawarich.
-Welcome to Dawarich! We're excited to have you on board.
+Welcome to Dawarich! I'm excited to have you on board.
Your 7-day free trial has started. During this time, you can:
- Track your location data
@@ -12,7 +12,7 @@ Your 7-day free trial has started. During this time, you can:
Start exploring Dawarich: https://my.dawarich.app
-If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.
+If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.
Happy tracking!
Evgenii from Dawarich
diff --git a/config/initializers/cache_jobs.rb b/config/initializers/cache_jobs.rb
index 0cc21349..9e89cbf9 100644
--- a/config/initializers/cache_jobs.rb
+++ b/config/initializers/cache_jobs.rb
@@ -3,10 +3,8 @@
Rails.application.config.after_initialize do
# Only run in server mode and ensure one-time execution with atomic write
if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true)
- # Clear the cache
Cache::CleaningJob.perform_later
- # Preheat the cache
Cache::PreheatingJob.perform_later
end
end
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index e93579b9..7b207ed3 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -302,7 +302,7 @@ Devise.setup do |config|
# When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true
- config.responder.error_status = :unprocessable_entity
+ config.responder.error_status = :unprocessable_content
config.responder.redirect_status = :see_other
if Rails.env.production? && !DawarichSettings.self_hosted?
diff --git a/config/initializers/rails_icons.rb b/config/initializers/rails_icons.rb
new file mode 100644
index 00000000..b0d79e40
--- /dev/null
+++ b/config/initializers/rails_icons.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+RailsIcons.configure do |config|
+ config.default_library = 'lucide'
+ # config.default_variant = "" # Set a default variant for all libraries
+
+ # Override Lucide defaults
+ # config.libraries.lucide.default_variant = "" # Set a default variant for Lucide
+ # config.libraries.lucide.exclude_variants = [] # Exclude specific variants
+
+ # config.libraries.lucide.outline.default.css = "size-6"
+ # config.libraries.lucide.outline.default.stroke_width = "1.5"
+ # config.libraries.lucide.outline.default.data = {}
+end
diff --git a/config/routes.rb b/config/routes.rb
index 1c3b2dda..1a592e5a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -70,10 +70,18 @@ Rails.application.routes.draw do
end
end
get 'stats/:year', to: 'stats#show', constraints: { year: /\d{4}/ }
+ get 'stats/:year/:month', to: 'stats#month', constraints: { year: /\d{4}/, month: /(0?[1-9]|1[0-2])/ }
put 'stats/:year/:month/update',
to: 'stats#update',
as: :update_year_month_stats,
constraints: { year: /\d{4}/, month: /\d{1,2}|all/ }
+ get 'shared/stats/:uuid', to: 'shared/stats#show', as: :shared_stat
+
+ # Sharing management endpoint (requires auth)
+ patch 'stats/:year/:month/sharing',
+ to: 'shared/stats#update',
+ as: :sharing_stats,
+ constraints: { year: /\d{4}/, month: /\d{1,2}/ }
root to: 'home#index'
@@ -140,6 +148,11 @@ Rails.application.routes.draw do
namespace :maps do
resources :tile_usage, only: [:create]
+ resources :hexagons, only: [:index] do
+ collection do
+ get :bounds
+ end
+ end
end
post 'subscriptions/callback', to: 'subscriptions#callback'
diff --git a/config/schedule.yml b/config/schedule.yml
index 0dc3c9e8..f0fcb40a 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -30,12 +30,12 @@ cache_preheating_job:
class: "Cache::PreheatingJob"
queue: default
-# tracks_cleanup_job:
-# cron: "0 2 * * 0" # every Sunday at 02:00
-# class: "Tracks::CleanupJob"
-# queue: tracks
-
place_name_fetching_job:
cron: "30 0 * * *" # every day at 00:30
class: "Places::BulkNameFetchingJob"
queue: places
+
+daily_track_generation_job:
+ cron: "0 */4 * * *" # every 4 hours
+ class: "Tracks::DailyGenerationJob"
+ queue: tracks
diff --git a/db/data/20250704185707_create_tracks_from_points.rb b/db/data/20250704185707_create_tracks_from_points.rb
index 2972eac4..4860841f 100644
--- a/db/data/20250704185707_create_tracks_from_points.rb
+++ b/db/data/20250704185707_create_tracks_from_points.rb
@@ -2,33 +2,9 @@
class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
def up
- puts "Starting bulk track creation for all users..."
+ # this data migration used to create tracks from existing points. It was deprecated
- total_users = User.count
- processed_users = 0
-
- User.find_each do |user|
- points_count = user.points.count
-
- if points_count > 0
- puts "Enqueuing track creation for user #{user.id} (#{points_count} points)"
-
- # Use explicit parameters for bulk historical processing:
- # - No time limits (start_at: nil, end_at: nil) = process ALL historical data
- Tracks::CreateJob.perform_later(
- user.id,
- start_at: nil,
- end_at: nil,
- mode: :bulk
- )
-
- processed_users += 1
- else
- puts "Skipping user #{user.id} (no tracked points)"
- end
- end
-
- puts "Enqueued track creation jobs for #{processed_users}/#{total_users} users"
+ nil
end
def down
diff --git a/db/data/20250709195003_recalculate_trips_distance.rb b/db/data/20250709195003_recalculate_trips_distance.rb
index 6c02bd3a..9353ac18 100644
--- a/db/data/20250709195003_recalculate_trips_distance.rb
+++ b/db/data/20250709195003_recalculate_trips_distance.rb
@@ -2,9 +2,7 @@
class RecalculateTripsDistance < ActiveRecord::Migration[8.0]
def up
- Trip.find_each do |trip|
- trip.enqueue_calculation_jobs
- end
+ Trip.find_each(&:enqueue_calculation_jobs)
end
def down
diff --git a/db/migrate/20220325100310_devise_create_users.rb b/db/migrate/20220325100310_devise_create_users.rb
index 43927dbd..e052375d 100644
--- a/db/migrate/20220325100310_devise_create_users.rb
+++ b/db/migrate/20220325100310_devise_create_users.rb
@@ -4,8 +4,8 @@ class DeviseCreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
## Database authenticatable
- t.string :email, null: false, default: ""
- t.string :encrypted_password, null: false, default: ""
+ t.string :email, null: false, default: ''
+ t.string :encrypted_password, null: false, default: ''
## Recoverable
t.string :reset_password_token
@@ -32,7 +32,6 @@ class DeviseCreateUsers < ActiveRecord::Migration[7.0]
# t.string :unlock_token # Only if unlock strategy is :email or :both
# t.datetime :locked_at
-
t.timestamps null: false
end
diff --git a/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb b/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb
new file mode 100644
index 00000000..afe2643a
--- /dev/null
+++ b/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class AddUserCountryCompositeIndexToPoints < ActiveRecord::Migration[8.0]
+ disable_ddl_transaction!
+
+ def change
+ add_index :points, %i[user_id country_name],
+ algorithm: :concurrently,
+ name: 'idx_points_user_country_name',
+ if_not_exists: true
+ end
+end
diff --git a/db/migrate/20250910224538_add_sharing_fields_to_stats.rb b/db/migrate/20250910224538_add_sharing_fields_to_stats.rb
new file mode 100644
index 00000000..b3194d82
--- /dev/null
+++ b/db/migrate/20250910224538_add_sharing_fields_to_stats.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddSharingFieldsToStats < ActiveRecord::Migration[8.0]
+ def change
+ add_column :stats, :sharing_settings, :jsonb, default: {}
+ add_column :stats, :sharing_uuid, :uuid
+ end
+end
diff --git a/db/migrate/20250910224714_add_index_to_stats_share_uuid.rb b/db/migrate/20250910224714_add_index_to_stats_share_uuid.rb
new file mode 100644
index 00000000..6e1d0bc3
--- /dev/null
+++ b/db/migrate/20250910224714_add_index_to_stats_share_uuid.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddIndexToStatsShareUuid < ActiveRecord::Migration[8.0]
+ disable_ddl_transaction!
+
+ def change
+ add_index :stats, :sharing_uuid, unique: true, algorithm: :concurrently
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 6cb87072..cfcab1ea 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
+ActiveRecord::Schema[8.0].define(version: 2025_09_10_224714) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@@ -205,6 +205,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["track_id"], name: "index_points_on_track_id"
t.index ["trigger"], name: "index_points_on_trigger"
+ t.index ["user_id", "country_name"], name: "idx_points_user_country_name"
t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation"
t.index ["user_id"], name: "index_points_on_user_id"
t.index ["visit_id"], name: "index_points_on_visit_id"
@@ -219,8 +220,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.jsonb "daily_distance", default: {}
+ t.jsonb "sharing_settings", default: {}
+ t.uuid "sharing_uuid"
t.index ["distance"], name: "index_stats_on_distance"
t.index ["month"], name: "index_stats_on_month"
+ t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true
t.index ["user_id"], name: "index_stats_on_user_id"
t.index ["year"], name: "index_stats_on_year"
end
diff --git a/spec/factories/stats.rb b/spec/factories/stats.rb
index 4a2ade2a..724ddbfa 100644
--- a/spec/factories/stats.rb
+++ b/spec/factories/stats.rb
@@ -6,6 +6,8 @@ FactoryBot.define do
month { 1 }
distance { 1000 } # 1 km
user
+ sharing_settings { {} }
+ sharing_uuid { SecureRandom.uuid }
toponyms do
[
{
@@ -16,5 +18,31 @@ FactoryBot.define do
}, { 'cities' => [], 'country' => nil }
]
end
+
+ trait :with_sharing_enabled do
+ after(:create) do |stat, _evaluator|
+ stat.enable_sharing!(expiration: 'permanent')
+ end
+ end
+
+ trait :with_sharing_disabled do
+ sharing_settings do
+ {
+ 'enabled' => false,
+ 'expiration' => nil,
+ 'expires_at' => nil
+ }
+ end
+ end
+
+ trait :with_sharing_expired do
+ sharing_settings do
+ {
+ 'enabled' => true,
+ 'expiration' => '1h',
+ 'expires_at' => 1.hour.ago.iso8601
+ }
+ end
+ end
end
end
diff --git a/spec/jobs/bulk_stats_calculating_job_spec.rb b/spec/jobs/bulk_stats_calculating_job_spec.rb
index 632fa47e..eb59c46a 100644
--- a/spec/jobs/bulk_stats_calculating_job_spec.rb
+++ b/spec/jobs/bulk_stats_calculating_job_spec.rb
@@ -4,28 +4,153 @@ require 'rails_helper'
RSpec.describe BulkStatsCalculatingJob, type: :job do
describe '#perform' do
- let(:user1) { create(:user) }
- let(:user2) { create(:user) }
-
let(:timestamp) { DateTime.new(2024, 1, 1).to_i }
- let!(:points1) do
- (1..10).map do |i|
- create(:point, user_id: user1.id, timestamp: timestamp + i.minutes)
+ context 'with active users' do
+ let!(:active_user1) { create(:user, status: :active) }
+ let!(:active_user2) { create(:user, status: :active) }
+
+ let!(:points1) do
+ (1..10).map do |i|
+ create(:point, user_id: active_user1.id, timestamp: timestamp + i.minutes)
+ end
+ end
+
+ let!(:points2) do
+ (1..10).map do |i|
+ create(:point, user_id: active_user2.id, timestamp: timestamp + i.minutes)
+ end
+ end
+
+ before do
+ # Remove any leftover users from other tests, keeping only our test users
+ User.where.not(id: [active_user1.id, active_user2.id]).destroy_all
+ allow(Stats::BulkCalculator).to receive(:new).and_call_original
+ allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
+ end
+
+ it 'processes all active users' do
+ BulkStatsCalculatingJob.perform_now
+
+ expect(Stats::BulkCalculator).to have_received(:new).with(active_user1.id)
+ expect(Stats::BulkCalculator).to have_received(:new).with(active_user2.id)
+ end
+
+ it 'calls Stats::BulkCalculator for each active user' do
+ calculator1 = instance_double(Stats::BulkCalculator)
+ calculator2 = instance_double(Stats::BulkCalculator)
+
+ allow(Stats::BulkCalculator).to receive(:new).with(active_user1.id).and_return(calculator1)
+ allow(Stats::BulkCalculator).to receive(:new).with(active_user2.id).and_return(calculator2)
+ allow(calculator1).to receive(:call)
+ allow(calculator2).to receive(:call)
+
+ BulkStatsCalculatingJob.perform_now
+
+ expect(calculator1).to have_received(:call)
+ expect(calculator2).to have_received(:call)
end
end
- let!(:points2) do
- (1..10).map do |i|
- create(:point, user_id: user2.id, timestamp: timestamp + i.minutes)
+ context 'with trial users' do
+ let!(:trial_user1) { create(:user, status: :trial) }
+ let!(:trial_user2) { create(:user, status: :trial) }
+
+ let!(:points1) do
+ (1..5).map do |i|
+ create(:point, user_id: trial_user1.id, timestamp: timestamp + i.minutes)
+ end
+ end
+
+ let!(:points2) do
+ (1..5).map do |i|
+ create(:point, user_id: trial_user2.id, timestamp: timestamp + i.minutes)
+ end
+ end
+
+ before do
+ # Remove any leftover users from other tests, keeping only our test users
+ User.where.not(id: [trial_user1.id, trial_user2.id]).destroy_all
+ allow(Stats::BulkCalculator).to receive(:new).and_call_original
+ allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
+ end
+
+ it 'processes all trial users' do
+ BulkStatsCalculatingJob.perform_now
+
+ expect(Stats::BulkCalculator).to have_received(:new).with(trial_user1.id)
+ expect(Stats::BulkCalculator).to have_received(:new).with(trial_user2.id)
+ end
+
+ it 'calls Stats::BulkCalculator for each trial user' do
+ calculator1 = instance_double(Stats::BulkCalculator)
+ calculator2 = instance_double(Stats::BulkCalculator)
+
+ allow(Stats::BulkCalculator).to receive(:new).with(trial_user1.id).and_return(calculator1)
+ allow(Stats::BulkCalculator).to receive(:new).with(trial_user2.id).and_return(calculator2)
+ allow(calculator1).to receive(:call)
+ allow(calculator2).to receive(:call)
+
+ BulkStatsCalculatingJob.perform_now
+
+ expect(calculator1).to have_received(:call)
+ expect(calculator2).to have_received(:call)
end
end
- it 'enqueues Stats::CalculatingJob for each user' do
- expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1)
- expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1)
+ context 'with inactive users only' do
+ before do
+ allow(User).to receive(:active).and_return(User.none)
+ allow(User).to receive(:trial).and_return(User.none)
+ allow(Stats::BulkCalculator).to receive(:new)
+ end
- BulkStatsCalculatingJob.perform_now
+ it 'does not process any users when no active or trial users exist' do
+ BulkStatsCalculatingJob.perform_now
+
+ expect(Stats::BulkCalculator).not_to have_received(:new)
+ end
+
+ it 'queries for active and trial users but finds none' do
+ BulkStatsCalculatingJob.perform_now
+
+ expect(User).to have_received(:active)
+ expect(User).to have_received(:trial)
+ end
+ end
+
+ context 'with mixed user types' do
+ let(:active_user) { create(:user, status: :active) }
+ let(:trial_user) { create(:user, status: :trial) }
+ let(:inactive_user) { create(:user, status: :inactive) }
+
+ before do
+ active_users_relation = double('ActiveRecord::Relation')
+ trial_users_relation = double('ActiveRecord::Relation')
+
+ allow(active_users_relation).to receive(:pluck).with(:id).and_return([active_user.id])
+ allow(trial_users_relation).to receive(:pluck).with(:id).and_return([trial_user.id])
+
+ allow(User).to receive(:active).and_return(active_users_relation)
+ allow(User).to receive(:trial).and_return(trial_users_relation)
+
+ allow(Stats::BulkCalculator).to receive(:new).and_call_original
+ allow_any_instance_of(Stats::BulkCalculator).to receive(:call)
+ end
+
+ it 'processes only active and trial users, skipping inactive users' do
+ BulkStatsCalculatingJob.perform_now
+
+ expect(Stats::BulkCalculator).to have_received(:new).with(active_user.id)
+ expect(Stats::BulkCalculator).to have_received(:new).with(trial_user.id)
+ expect(Stats::BulkCalculator).not_to have_received(:new).with(inactive_user.id)
+ end
+
+ it 'processes exactly 2 users (active and trial)' do
+ BulkStatsCalculatingJob.perform_now
+
+ expect(Stats::BulkCalculator).to have_received(:new).exactly(2).times
+ end
end
end
end
diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb
new file mode 100644
index 00000000..fc809194
--- /dev/null
+++ b/spec/jobs/cache/preheating_job_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Cache::PreheatingJob do
+ before { Rails.cache.clear }
+
+ describe '#perform' do
+ let!(:user1) { create(:user) }
+ let!(:user2) { create(:user) }
+ let!(:import1) { create(:import, user: user1) }
+ let!(:import2) { create(:import, user: user2) }
+ let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" }
+ let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" }
+ let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" }
+ let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" }
+ let(:user_1_countries_visited_key) { "dawarich/user_#{user1.id}_countries_visited" }
+ let(:user_2_countries_visited_key) { "dawarich/user_#{user2.id}_countries_visited" }
+ let(:user_1_cities_visited_key) { "dawarich/user_#{user1.id}_cities_visited" }
+ let(:user_2_cities_visited_key) { "dawarich/user_#{user2.id}_cities_visited" }
+
+ before do
+ create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current)
+ create_list(:point, 2, user: user2, import: import2, reverse_geocoded_at: Time.current)
+ end
+
+ it 'preheats years_tracked cache for all users' do
+ # Clear cache before test to ensure clean state
+ Rails.cache.clear
+
+ described_class.new.perform
+
+ # Verify that cache keys exist after job runs
+ expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
+ expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
+
+ # Verify the cached data is reasonable
+ user1_years = Rails.cache.read(user_1_years_tracked_key)
+ user2_years = Rails.cache.read(user_2_years_tracked_key)
+
+ expect(user1_years).to be_an(Array)
+ expect(user2_years).to be_an(Array)
+ end
+
+ it 'preheats points_geocoded_stats cache for all users' do
+ # Clear cache before test to ensure clean state
+ Rails.cache.clear
+
+ described_class.new.perform
+
+ # Verify that cache keys exist after job runs
+ expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
+ expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
+
+ # Verify the cached data has the expected structure
+ user1_stats = Rails.cache.read(user_1_points_geocoded_stats_key)
+ user2_stats = Rails.cache.read(user_2_points_geocoded_stats_key)
+
+ expect(user1_stats).to be_a(Hash)
+ expect(user1_stats).to have_key(:geocoded)
+ expect(user1_stats).to have_key(:without_data)
+ expect(user1_stats[:geocoded]).to eq(3)
+
+ expect(user2_stats).to be_a(Hash)
+ expect(user2_stats).to have_key(:geocoded)
+ expect(user2_stats).to have_key(:without_data)
+ expect(user2_stats[:geocoded]).to eq(2)
+ end
+
+ it 'actually writes to cache' do
+ described_class.new.perform
+
+ expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
+ expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
+ expect(Rails.cache.exist?(user_1_countries_visited_key)).to be true
+ expect(Rails.cache.exist?(user_1_cities_visited_key)).to be true
+ expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
+ expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
+ expect(Rails.cache.exist?(user_2_countries_visited_key)).to be true
+ expect(Rails.cache.exist?(user_2_cities_visited_key)).to be true
+ end
+
+ it 'handles users with no points gracefully' do
+ user_no_points = create(:user)
+
+ expect { described_class.new.perform }.not_to raise_error
+
+ cached_stats = Rails.cache.read("dawarich/user_#{user_no_points.id}_points_geocoded_stats")
+ expect(cached_stats).to eq({ geocoded: 0, without_data: 0 })
+ end
+ end
+end
diff --git a/spec/jobs/tracks/cleanup_job_spec.rb b/spec/jobs/tracks/cleanup_job_spec.rb
deleted file mode 100644
index 66cb6923..00000000
--- a/spec/jobs/tracks/cleanup_job_spec.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Tracks::CleanupJob, type: :job do
- let(:user) { create(:user) }
-
- describe '#perform' do
- context 'with old untracked points' do
- let!(:old_points) do
- create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i)
- create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 1.day.ago.to_i)
- end
- let!(:recent_points) do
- create_points_around(user: user, count: 2, base_lat: 20.0, timestamp: 1.hour.ago.to_i)
- end
- let(:generator) { instance_double(Tracks::Generator) }
-
- it 'processes only old untracked points' do
- expect(Tracks::Generator).to receive(:new)
- .and_return(generator)
-
- expect(generator).to receive(:call)
-
- described_class.new.perform(older_than: 1.day.ago)
- end
- end
-
- context 'with users having insufficient points' do
- let!(:single_point) do
- create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i)
- end
-
- it 'skips users with less than 2 points' do
- expect(Tracks::Generator).not_to receive(:new)
-
- described_class.new.perform(older_than: 1.day.ago)
- end
- end
-
- context 'with no old untracked points' do
- let(:track) { create(:track, user: user) }
- let!(:tracked_points) do
- create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 2.days.ago.to_i, track: track)
- end
-
- it 'does not process any users' do
- expect(Tracks::Generator).not_to receive(:new)
-
- described_class.new.perform(older_than: 1.day.ago)
- end
- end
-
- context 'with custom older_than parameter' do
- let!(:points) do
- create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 3.days.ago.to_i)
- end
- let(:generator) { instance_double(Tracks::Generator) }
-
- it 'uses custom threshold' do
- expect(Tracks::Generator).to receive(:new)
- .and_return(generator)
-
- expect(generator).to receive(:call)
-
- described_class.new.perform(older_than: 2.days.ago)
- end
- end
- end
-
- describe 'job configuration' do
- it 'uses tracks queue' do
- expect(described_class.queue_name).to eq('tracks')
- end
-
- it 'does not retry on failure' do
- expect(described_class.sidekiq_options_hash['retry']).to be false
- end
- end
-end
diff --git a/spec/jobs/tracks/create_job_spec.rb b/spec/jobs/tracks/create_job_spec.rb
deleted file mode 100644
index b23fea8d..00000000
--- a/spec/jobs/tracks/create_job_spec.rb
+++ /dev/null
@@ -1,134 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Tracks::CreateJob, type: :job do
- let(:user) { create(:user) }
-
- describe '#perform' do
- let(:generator_instance) { instance_double(Tracks::Generator) }
-
- before do
- allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
- allow(generator_instance).to receive(:call)
- allow(generator_instance).to receive(:call).and_return(2)
- end
-
- it 'calls the generator and creates a notification' do
- described_class.new.perform(user.id)
-
- expect(Tracks::Generator).to have_received(:new).with(
- user,
- start_at: nil,
- end_at: nil,
- mode: :daily
- )
- expect(generator_instance).to have_received(:call)
- end
-
- context 'with custom parameters' do
- let(:start_at) { 1.day.ago.beginning_of_day.to_i }
- let(:end_at) { 1.day.ago.end_of_day.to_i }
- let(:mode) { :daily }
-
- before do
- allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
- allow(generator_instance).to receive(:call)
- allow(generator_instance).to receive(:call).and_return(1)
- end
-
- it 'passes custom parameters to the generator' do
- described_class.new.perform(user.id, start_at: start_at, end_at: end_at, mode: mode)
-
- expect(Tracks::Generator).to have_received(:new).with(
- user,
- start_at: start_at,
- end_at: end_at,
- mode: :daily
- )
- expect(generator_instance).to have_received(:call)
- end
- end
-
- context 'when generator raises an error' do
- let(:error_message) { 'Something went wrong' }
-
- before do
- allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
- allow(generator_instance).to receive(:call).and_raise(StandardError, error_message)
- allow(ExceptionReporter).to receive(:call)
- end
-
- it 'reports the error using ExceptionReporter' do
- allow(ExceptionReporter).to receive(:call)
-
- described_class.new.perform(user.id)
-
- expect(ExceptionReporter).to have_received(:call).with(
- kind_of(StandardError),
- 'Failed to create tracks for user'
- )
- end
- end
-
- context 'when user does not exist' do
- before do
- allow(User).to receive(:find).with(999).and_raise(ActiveRecord::RecordNotFound)
- allow(ExceptionReporter).to receive(:call)
- allow(Notifications::Create).to receive(:new).and_return(instance_double(Notifications::Create, call: nil))
- end
-
- it 'handles the error gracefully and creates error notification' do
- expect { described_class.new.perform(999) }.not_to raise_error
-
- expect(ExceptionReporter).to have_received(:call)
- end
- end
-
- context 'when tracks are deleted and recreated' do
- let(:existing_tracks) { create_list(:track, 3, user: user) }
-
- before do
- allow(generator_instance).to receive(:call).and_return(2)
- end
-
- it 'returns the correct count of newly created tracks' do
- described_class.new.perform(user.id, mode: :incremental)
-
- expect(Tracks::Generator).to have_received(:new).with(
- user,
- start_at: nil,
- end_at: nil,
- mode: :incremental
- )
- expect(generator_instance).to have_received(:call)
- end
- end
- end
-
- describe 'queue' do
- it 'is queued on tracks queue' do
- expect(described_class.new.queue_name).to eq('tracks')
- end
- end
-
- context 'when not self-hosted' do
- let(:generator_instance) { instance_double(Tracks::Generator) }
- let(:notification_service) { instance_double(Notifications::Create) }
- let(:error_message) { 'Something went wrong' }
-
- before do
- allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
- allow(Tracks::Generator).to receive(:new).and_return(generator_instance)
- allow(generator_instance).to receive(:call).and_raise(StandardError, error_message)
- allow(Notifications::Create).to receive(:new).and_return(notification_service)
- allow(notification_service).to receive(:call)
- end
-
- it 'does not create a failure notification' do
- described_class.new.perform(user.id)
-
- expect(notification_service).not_to have_received(:call)
- end
- end
-end
diff --git a/spec/jobs/tracks/daily_generation_job_spec.rb b/spec/jobs/tracks/daily_generation_job_spec.rb
new file mode 100644
index 00000000..c23d9243
--- /dev/null
+++ b/spec/jobs/tracks/daily_generation_job_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Tracks::DailyGenerationJob, type: :job do
+ describe '#perform' do
+ let!(:active_user) { create(:user, settings: { 'minutes_between_routes' => 60, 'meters_between_routes' => 500 }) }
+ let!(:trial_user) { create(:user, :trial) }
+ let!(:inactive_user) { create(:user, :inactive) }
+
+ let!(:active_user_old_track) do
+ create(:track, user: active_user, start_at: 2.days.ago, end_at: 2.days.ago + 1.hour)
+ end
+ let!(:active_user_new_points) do
+ create_list(:point, 3, user: active_user, timestamp: 1.hour.ago.to_i)
+ end
+
+ let!(:trial_user_old_track) do
+ create(:track, user: trial_user, start_at: 3.days.ago, end_at: 3.days.ago + 1.hour)
+ end
+ let!(:trial_user_new_points) do
+ create_list(:point, 2, user: trial_user, timestamp: 30.minutes.ago.to_i)
+ end
+
+ before do
+ active_user.update!(points_count: active_user.points.count)
+ trial_user.update!(points_count: trial_user.points.count)
+
+ ActiveJob::Base.queue_adapter.enqueued_jobs.clear
+ end
+
+ it 'processes all active and trial users' do
+ expect { described_class.perform_now }.to \
+ have_enqueued_job(Tracks::ParallelGeneratorJob).twice
+ end
+
+ it 'does not process inactive users' do
+ # Clear points and tracks to make destruction possible
+ Point.destroy_all
+ Track.destroy_all
+
+ # Remove active and trial users to isolate test
+ active_user.destroy
+ trial_user.destroy
+
+ expect do
+ described_class.perform_now
+ end.not_to have_enqueued_job(Tracks::ParallelGeneratorJob)
+ end
+
+ it 'enqueues correct number of parallel generation jobs for users with new points' do
+ expect { described_class.perform_now }.to \
+ have_enqueued_job(Tracks::ParallelGeneratorJob).exactly(2).times
+ end
+
+ it 'enqueues parallel generation job for active user with correct parameters' do
+ expect { described_class.perform_now }.to \
+ have_enqueued_job(Tracks::ParallelGeneratorJob).with(
+ active_user.id,
+ hash_including(mode: 'daily')
+ )
+ end
+
+ it 'enqueues parallel generation job for trial user' do
+ expect { described_class.perform_now }.to \
+ have_enqueued_job(Tracks::ParallelGeneratorJob).with(
+ trial_user.id,
+ hash_including(mode: 'daily')
+ )
+ end
+
+ it 'does not enqueue jobs for users without new points' do
+ Point.destroy_all
+
+ expect { described_class.perform_now }.not_to \
+ have_enqueued_job(Tracks::ParallelGeneratorJob)
+ end
+
+ context 'when processing fails' do
+ before do
+ allow_any_instance_of(User).to receive(:tracks).and_raise(StandardError, 'Database error')
+ allow(ExceptionReporter).to receive(:call)
+
+ active_user.update!(points_count: 5)
+ trial_user.update!(points_count: 3)
+ end
+ it 'does not raise errors when processing fails' do
+ expect { described_class.perform_now }.not_to raise_error
+ end
+
+ it 'reports exceptions when processing fails' do
+ described_class.perform_now
+
+ expect(ExceptionReporter).to have_received(:call).at_least(:once)
+ end
+ end
+
+ context 'when user has no points' do
+ let!(:empty_user) { create(:user) }
+
+ it 'skips users with no points' do
+ expect { described_class.perform_now }.not_to \
+ have_enqueued_job(Tracks::ParallelGeneratorJob).with(empty_user.id, any_args)
+ end
+ end
+
+ context 'when user has tracks but no new points' do
+ let!(:user_with_current_tracks) { create(:user) }
+ let!(:recent_points) { create_list(:point, 2, user: user_with_current_tracks, timestamp: 1.hour.ago.to_i) }
+ let!(:recent_track) do
+ create(:track, user: user_with_current_tracks, start_at: 1.hour.ago, end_at: 30.minutes.ago)
+ end
+
+ before do
+ user_with_current_tracks.update!(points_count: user_with_current_tracks.points.count)
+ end
+
+ it 'skips users without new points since last track' do
+ expect { described_class.perform_now }.not_to \
+ have_enqueued_job(Tracks::ParallelGeneratorJob).with(user_with_current_tracks.id, any_args)
+ end
+ end
+ end
+end
diff --git a/spec/jobs/tracks/incremental_check_job_spec.rb b/spec/jobs/tracks/incremental_check_job_spec.rb
deleted file mode 100644
index c25d1299..00000000
--- a/spec/jobs/tracks/incremental_check_job_spec.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Tracks::IncrementalCheckJob, type: :job do
- let(:user) { create(:user) }
- let(:point) { create(:point, user: user) }
-
- describe '#perform' do
- context 'with valid parameters' do
- let(:processor) { instance_double(Tracks::IncrementalProcessor) }
-
- it 'calls the incremental processor' do
- expect(Tracks::IncrementalProcessor).to receive(:new)
- .with(user, point)
- .and_return(processor)
-
- expect(processor).to receive(:call)
-
- described_class.new.perform(user.id, point.id)
- end
- end
- end
-
- describe 'job configuration' do
- it 'uses tracks queue' do
- expect(described_class.queue_name).to eq('tracks')
- end
- end
-
- describe 'integration with ActiveJob' do
- it 'enqueues the job' do
- expect do
- described_class.perform_later(user.id, point.id)
- end.to have_enqueued_job(described_class)
- .with(user.id, point.id)
- end
- end
-end
diff --git a/spec/jobs/tracks/parallel_generator_job_spec.rb b/spec/jobs/tracks/parallel_generator_job_spec.rb
index 7428dd2c..75c34738 100644
--- a/spec/jobs/tracks/parallel_generator_job_spec.rb
+++ b/spec/jobs/tracks/parallel_generator_job_spec.rb
@@ -125,31 +125,10 @@ RSpec.describe Tracks::ParallelGeneratorJob do
describe 'integration with existing track job patterns' do
let!(:point) { create(:point, user: user, timestamp: 1.day.ago.to_i) }
- it 'follows the same notification pattern as Tracks::CreateJob' do
- # Compare with existing Tracks::CreateJob behavior
- # Should create similar notifications and handle errors similarly
-
- expect {
- job.perform(user.id)
- }.not_to raise_error
- end
-
it 'can be queued and executed' do
- expect {
+ expect do
described_class.perform_later(user.id)
- }.to have_enqueued_job(described_class).with(user.id)
- end
-
- it 'supports the same parameter structure as Tracks::CreateJob' do
- # Should accept the same parameters that would be passed to Tracks::CreateJob
- expect {
- described_class.perform_later(
- user.id,
- start_at: 1.week.ago,
- end_at: Time.current,
- mode: :daily
- )
- }.to have_enqueued_job(described_class)
+ end.to have_enqueued_job(described_class).with(user.id)
end
end
end
diff --git a/spec/jobs/users/mailer_sending_job_spec.rb b/spec/jobs/users/mailer_sending_job_spec.rb
index ba4b1de9..b6b80a9e 100644
--- a/spec/jobs/users/mailer_sending_job_spec.rb
+++ b/spec/jobs/users/mailer_sending_job_spec.rb
@@ -108,37 +108,13 @@ RSpec.describe Users::MailerSendingJob, type: :job do
end
context 'when user is deleted' do
- it 'raises ActiveRecord::RecordNotFound' do
+ it 'does not raise an error' do
user.destroy
- expect {
+ expect do
described_class.perform_now(user.id, 'welcome')
- }.to raise_error(ActiveRecord::RecordNotFound)
+ end.not_to raise_error
end
end
end
-
- describe '#trial_related_email?' do
- subject { described_class.new }
-
- it 'returns true for trial_expires_soon' do
- expect(subject.send(:trial_related_email?, 'trial_expires_soon')).to be true
- end
-
- it 'returns true for trial_expired' do
- expect(subject.send(:trial_related_email?, 'trial_expired')).to be true
- end
-
- it 'returns false for welcome' do
- expect(subject.send(:trial_related_email?, 'welcome')).to be false
- end
-
- it 'returns false for explore_features' do
- expect(subject.send(:trial_related_email?, 'explore_features')).to be false
- end
-
- it 'returns false for unknown email types' do
- expect(subject.send(:trial_related_email?, 'unknown_email')).to be false
- end
- end
end
diff --git a/spec/mailers/users_mailer_spec.rb b/spec/mailers/users_mailer_spec.rb
index 11789e2b..9d0195e3 100644
--- a/spec/mailers/users_mailer_spec.rb
+++ b/spec/mailers/users_mailer_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require "rails_helper"
+require 'rails_helper'
RSpec.describe UsersMailer, type: :mailer do
let(:user) { create(:user, email: 'test@example.com') }
@@ -9,43 +9,61 @@ RSpec.describe UsersMailer, type: :mailer do
stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app'))
end
- describe "welcome" do
+ describe 'welcome' do
let(:mail) { UsersMailer.with(user: user).welcome }
- it "renders the headers" do
- expect(mail.subject).to eq("Welcome to Dawarich!")
- expect(mail.to).to eq(["test@example.com"])
+ it 'renders the headers' do
+ expect(mail.subject).to eq('Welcome to Dawarich!')
+ expect(mail.to).to eq(['test@example.com'])
end
- it "renders the body" do
- expect(mail.body.encoded).to match("test@example.com")
+ it 'renders the body' do
+ expect(mail.body.encoded).to match('test@example.com')
end
end
- describe "explore_features" do
+ describe 'explore_features' do
let(:mail) { UsersMailer.with(user: user).explore_features }
- it "renders the headers" do
- expect(mail.subject).to eq("Explore Dawarich features!")
- expect(mail.to).to eq(["test@example.com"])
+ it 'renders the headers' do
+ expect(mail.subject).to eq('Explore Dawarich features!')
+ expect(mail.to).to eq(['test@example.com'])
end
end
- describe "trial_expires_soon" do
+ describe 'trial_expires_soon' do
let(:mail) { UsersMailer.with(user: user).trial_expires_soon }
- it "renders the headers" do
- expect(mail.subject).to eq("⚠️ Your Dawarich trial expires in 2 days")
- expect(mail.to).to eq(["test@example.com"])
+ it 'renders the headers' do
+ expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days')
+ expect(mail.to).to eq(['test@example.com'])
end
end
- describe "trial_expired" do
+ describe 'trial_expired' do
let(:mail) { UsersMailer.with(user: user).trial_expired }
- it "renders the headers" do
- expect(mail.subject).to eq("💔 Your Dawarich trial expired")
- expect(mail.to).to eq(["test@example.com"])
+ it 'renders the headers' do
+ expect(mail.subject).to eq('💔 Your Dawarich trial expired')
+ expect(mail.to).to eq(['test@example.com'])
+ end
+ end
+
+ describe 'post_trial_reminder_early' do
+ let(:mail) { UsersMailer.with(user: user).post_trial_reminder_early }
+
+ it 'renders the headers' do
+ expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!')
+ expect(mail.to).to eq(['test@example.com'])
+ end
+ end
+
+ describe 'post_trial_reminder_late' do
+ let(:mail) { UsersMailer.with(user: user).post_trial_reminder_late }
+
+ it 'renders the headers' do
+ expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich')
+ expect(mail.to).to eq(['test@example.com'])
end
end
end
diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb
index ee4c477f..e6a5cadb 100644
--- a/spec/models/stat_spec.rb
+++ b/spec/models/stat_spec.rb
@@ -102,5 +102,165 @@ RSpec.describe Stat, type: :model do
expect(subject).to eq(points)
end
end
+
+ describe '#calculate_data_bounds' do
+ let(:stat) { create(:stat, year: 2024, month: 6, user:) }
+ let(:user) { create(:user) }
+
+ context 'when stat has points' do
+ before do
+ # Create test points within the month (June 2024)
+ create(:point,
+ user:,
+ latitude: 40.6,
+ longitude: -74.1,
+ timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
+ create(:point,
+ user:,
+ latitude: 40.8,
+ longitude: -73.9,
+ timestamp: Time.new(2024, 6, 15, 15, 0).to_i)
+ create(:point,
+ user:,
+ latitude: 40.7,
+ longitude: -74.0,
+ timestamp: Time.new(2024, 6, 30, 18, 0).to_i)
+
+ # Points outside the month (should be ignored)
+ create(:point,
+ user:,
+ latitude: 41.0,
+ longitude: -75.0,
+ timestamp: Time.new(2024, 5, 31, 23, 59).to_i) # May
+ create(:point,
+ user:,
+ latitude: 39.0,
+ longitude: -72.0,
+ timestamp: Time.new(2024, 7, 1, 0, 1).to_i) # July
+ end
+
+ it 'returns correct bounding box for points within the month' do
+ result = stat.calculate_data_bounds
+
+ expect(result).to be_a(Hash)
+ expect(result[:min_lat]).to eq(40.6)
+ expect(result[:max_lat]).to eq(40.8)
+ expect(result[:min_lng]).to eq(-74.1)
+ expect(result[:max_lng]).to eq(-73.9)
+ expect(result[:point_count]).to eq(3)
+ end
+
+ context 'with points from different users' do
+ let(:other_user) { create(:user) }
+
+ before do
+ # Add points from a different user (should be ignored)
+ create(:point,
+ user: other_user,
+ latitude: 50.0,
+ longitude: -80.0,
+ timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
+ end
+
+ it 'only includes points from the stat user' do
+ result = stat.calculate_data_bounds
+
+ expect(result[:min_lat]).to eq(40.6)
+ expect(result[:max_lat]).to eq(40.8)
+ expect(result[:min_lng]).to eq(-74.1)
+ expect(result[:max_lng]).to eq(-73.9)
+ expect(result[:point_count]).to eq(3) # Still only 3 points from the stat user
+ end
+ end
+
+ context 'with single point' do
+ let(:single_point_user) { create(:user) }
+ let(:single_point_stat) { create(:stat, year: 2024, month: 7, user: single_point_user) }
+
+ before do
+ create(:point,
+ user: single_point_user,
+ latitude: 45.5,
+ longitude: -122.65,
+ timestamp: Time.new(2024, 7, 15, 14, 30).to_i)
+ end
+
+ it 'returns bounds with same min and max values' do
+ result = single_point_stat.calculate_data_bounds
+
+ expect(result[:min_lat]).to eq(45.5)
+ expect(result[:max_lat]).to eq(45.5)
+ expect(result[:min_lng]).to eq(-122.65)
+ expect(result[:max_lng]).to eq(-122.65)
+ expect(result[:point_count]).to eq(1)
+ end
+ end
+
+ context 'with edge case coordinates' do
+ let(:edge_user) { create(:user) }
+ let(:edge_stat) { create(:stat, year: 2024, month: 8, user: edge_user) }
+
+ before do
+ # Test with extreme coordinate values
+ create(:point,
+ user: edge_user,
+ latitude: -90.0, # South Pole
+ longitude: -180.0, # Date Line West
+ timestamp: Time.new(2024, 8, 1, 0, 0).to_i)
+ create(:point,
+ user: edge_user,
+ latitude: 90.0, # North Pole
+ longitude: 180.0, # Date Line East
+ timestamp: Time.new(2024, 8, 31, 23, 59).to_i)
+ end
+
+ it 'handles extreme coordinate values correctly' do
+ result = edge_stat.calculate_data_bounds
+
+ expect(result[:min_lat]).to eq(-90.0)
+ expect(result[:max_lat]).to eq(90.0)
+ expect(result[:min_lng]).to eq(-180.0)
+ expect(result[:max_lng]).to eq(180.0)
+ expect(result[:point_count]).to eq(2)
+ end
+ end
+ end
+
+ context 'when stat has no points' do
+ let(:empty_user) { create(:user) }
+ let(:empty_stat) { create(:stat, year: 2024, month: 10, user: empty_user) }
+
+ it 'returns nil' do
+ result = empty_stat.calculate_data_bounds
+
+ expect(result).to be_nil
+ end
+ end
+
+ context 'when stat has points but none within the month timeframe' do
+ let(:empty_month_user) { create(:user) }
+ let(:empty_month_stat) { create(:stat, year: 2024, month: 9, user: empty_month_user) }
+
+ before do
+ # Create points outside the target month
+ create(:point,
+ user: empty_month_user,
+ latitude: 40.7,
+ longitude: -74.0,
+ timestamp: Time.new(2024, 8, 31, 23, 59).to_i) # August
+ create(:point,
+ user: empty_month_user,
+ latitude: 40.8,
+ longitude: -73.9,
+ timestamp: Time.new(2024, 10, 1, 0, 1).to_i) # October
+ end
+
+ it 'returns nil when no points exist in the month' do
+ result = empty_month_stat.calculate_data_bounds
+
+ expect(result).to be_nil
+ end
+ end
+ end
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 94c225c5..928df596 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -329,5 +329,11 @@ RSpec.describe User, type: :model do
expect { user.export_data }.to have_enqueued_job(Users::ExportDataJob).with(user.id)
end
end
+
+ describe '#timezone' do
+ it 'returns the app timezone' do
+ expect(user.timezone).to eq(Time.zone.name)
+ end
+ end
end
end
diff --git a/spec/queries/hexagon_query_spec.rb b/spec/queries/hexagon_query_spec.rb
new file mode 100644
index 00000000..b9bf8183
--- /dev/null
+++ b/spec/queries/hexagon_query_spec.rb
@@ -0,0 +1,245 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe HexagonQuery, type: :query do
+ let(:user) { create(:user) }
+ let(:min_lon) { -74.1 }
+ let(:min_lat) { 40.6 }
+ let(:max_lon) { -73.9 }
+ let(:max_lat) { 40.8 }
+ let(:hex_size) { 500 }
+
+ describe '#initialize' do
+ it 'sets required parameters' do
+ query = described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size
+ )
+
+ expect(query.min_lon).to eq(min_lon)
+ expect(query.min_lat).to eq(min_lat)
+ expect(query.max_lon).to eq(max_lon)
+ expect(query.max_lat).to eq(max_lat)
+ expect(query.hex_size).to eq(hex_size)
+ end
+
+ it 'sets optional parameters' do
+ start_date = '2024-06-01T00:00:00Z'
+ end_date = '2024-06-30T23:59:59Z'
+
+ query = described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size,
+ user_id: user.id,
+ start_date: start_date,
+ end_date: end_date
+ )
+
+ expect(query.user_id).to eq(user.id)
+ expect(query.start_date).to eq(start_date)
+ expect(query.end_date).to eq(end_date)
+ end
+ end
+
+ describe '#call' do
+ let(:query) do
+ described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size,
+ user_id: user.id
+ )
+ end
+
+ context 'with no points' do
+ it 'executes without error and returns empty result' do
+ result = query.call
+ expect(result.to_a).to be_empty
+ end
+ end
+
+ context 'with points in bounding box' do
+ before do
+ # Create test points within the bounding box
+ create(:point,
+ user:,
+ latitude: 40.7,
+ longitude: -74.0,
+ timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
+ create(:point,
+ user:,
+ latitude: 40.75,
+ longitude: -73.95,
+ timestamp: Time.new(2024, 6, 16, 14, 0).to_i)
+ end
+
+ it 'returns hexagon results with expected structure' do
+ result = query.call
+ result_array = result.to_a
+
+ expect(result_array).not_to be_empty
+
+ first_hex = result_array.first
+ expect(first_hex).to have_key('geojson')
+ expect(first_hex).to have_key('hex_i')
+ expect(first_hex).to have_key('hex_j')
+ expect(first_hex).to have_key('point_count')
+ expect(first_hex).to have_key('earliest_point')
+ expect(first_hex).to have_key('latest_point')
+ expect(first_hex).to have_key('id')
+
+ # Verify geojson can be parsed
+ geojson = JSON.parse(first_hex['geojson'])
+ expect(geojson).to have_key('type')
+ expect(geojson).to have_key('coordinates')
+ end
+
+ it 'filters by user_id correctly' do
+ other_user = create(:user)
+ # Create points for a different user (should be excluded)
+ create(:point,
+ user: other_user,
+ latitude: 40.72,
+ longitude: -73.98,
+ timestamp: Time.new(2024, 6, 17, 16, 0).to_i)
+
+ result = query.call
+ result_array = result.to_a
+
+ # Should only include hexagons with the specified user's points
+ total_points = result_array.sum { |row| row['point_count'].to_i }
+ expect(total_points).to eq(2) # Only the 2 points from our user
+ end
+ end
+
+ context 'with date filtering' do
+ let(:query_with_dates) do
+ described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size,
+ user_id: user.id,
+ start_date: '2024-06-15T00:00:00Z',
+ end_date: '2024-06-16T23:59:59Z'
+ )
+ end
+
+ before do
+ # Create points within and outside the date range
+ create(:point,
+ user:,
+ latitude: 40.7,
+ longitude: -74.0,
+ timestamp: Time.new(2024, 6, 15, 12, 0).to_i) # Within range
+ create(:point,
+ user:,
+ latitude: 40.71,
+ longitude: -74.01,
+ timestamp: Time.new(2024, 6, 20, 12, 0).to_i) # Outside range
+ end
+
+ it 'filters points by date range' do
+ result = query_with_dates.call
+ result_array = result.to_a
+
+ expect(result_array).not_to be_empty
+
+ # Should only include the point within the date range
+ total_points = result_array.sum { |row| row['point_count'].to_i }
+ expect(total_points).to eq(1)
+ end
+ end
+
+ context 'without user_id filter' do
+ let(:query_no_user) do
+ described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size
+ )
+ end
+
+ before do
+ user1 = create(:user)
+ user2 = create(:user)
+
+ create(:point, user: user1, latitude: 40.7, longitude: -74.0, timestamp: Time.current.to_i)
+ create(:point, user: user2, latitude: 40.75, longitude: -73.95, timestamp: Time.current.to_i)
+ end
+
+ it 'includes points from all users' do
+ result = query_no_user.call
+ result_array = result.to_a
+
+ expect(result_array).not_to be_empty
+
+ # Should include points from both users
+ total_points = result_array.sum { |row| row['point_count'].to_i }
+ expect(total_points).to eq(2)
+ end
+ end
+ end
+
+ describe '#build_date_filter (private method behavior)' do
+ context 'when testing date filter behavior through query execution' do
+ it 'works correctly with start_date only' do
+ query = described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size,
+ user_id: user.id,
+ start_date: '2024-06-15T00:00:00Z'
+ )
+
+ # Should execute without SQL syntax errors
+ expect { query.call }.not_to raise_error
+ end
+
+ it 'works correctly with end_date only' do
+ query = described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size,
+ user_id: user.id,
+ end_date: '2024-06-30T23:59:59Z'
+ )
+
+ # Should execute without SQL syntax errors
+ expect { query.call }.not_to raise_error
+ end
+
+ it 'works correctly with both start_date and end_date' do
+ query = described_class.new(
+ min_lon: min_lon,
+ min_lat: min_lat,
+ max_lon: max_lon,
+ max_lat: max_lat,
+ hex_size: hex_size,
+ user_id: user.id,
+ start_date: '2024-06-01T00:00:00Z',
+ end_date: '2024-06-30T23:59:59Z'
+ )
+
+ # Should execute without SQL syntax errors
+ expect { query.call }.not_to raise_error
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/queries/stats_query_spec.rb b/spec/queries/stats_query_spec.rb
index d4d8517f..8efcbb81 100644
--- a/spec/queries/stats_query_spec.rb
+++ b/spec/queries/stats_query_spec.rb
@@ -3,6 +3,8 @@
require 'rails_helper'
RSpec.describe StatsQuery do
+ before { Rails.cache.clear }
+
describe '#points_stats' do
subject(:points_stats) { described_class.new(user).points_stats }
@@ -11,11 +13,13 @@ RSpec.describe StatsQuery do
context 'when user has no points' do
it 'returns zero counts for all statistics' do
- expect(points_stats).to eq({
- total: 0,
- geocoded: 0,
- without_data: 0
- })
+ expect(points_stats).to eq(
+ {
+ total: 0,
+ geocoded: 0,
+ without_data: 0
+ }
+ )
end
end
@@ -45,11 +49,13 @@ RSpec.describe StatsQuery do
end
it 'returns correct counts for all statistics' do
- expect(points_stats).to eq({
- total: 3,
- geocoded: 2,
- without_data: 1
- })
+ expect(points_stats).to eq(
+ {
+ total: 3,
+ geocoded: 2,
+ without_data: 1
+ }
+ )
end
context 'when another user has points' do
@@ -64,11 +70,13 @@ RSpec.describe StatsQuery do
end
it 'only counts points for the specified user' do
- expect(points_stats).to eq({
- total: 3,
- geocoded: 2,
- without_data: 1
- })
+ expect(points_stats).to eq(
+ {
+ total: 3,
+ geocoded: 2,
+ without_data: 1
+ }
+ )
end
end
end
@@ -83,11 +91,13 @@ RSpec.describe StatsQuery do
end
it 'returns correct statistics' do
- expect(points_stats).to eq({
- total: 5,
- geocoded: 5,
- without_data: 0
- })
+ expect(points_stats).to eq(
+ {
+ total: 5,
+ geocoded: 5,
+ without_data: 0
+ }
+ )
end
end
@@ -101,11 +111,13 @@ RSpec.describe StatsQuery do
end
it 'returns correct statistics' do
- expect(points_stats).to eq({
- total: 3,
- geocoded: 3,
- without_data: 3
- })
+ expect(points_stats).to eq(
+ {
+ total: 3,
+ geocoded: 3,
+ without_data: 3
+ }
+ )
end
end
@@ -119,12 +131,55 @@ RSpec.describe StatsQuery do
end
it 'returns correct statistics' do
- expect(points_stats).to eq({
- total: 4,
- geocoded: 0,
- without_data: 0
- })
+ expect(points_stats).to eq(
+ {
+ total: 4,
+ geocoded: 0,
+ without_data: 0
+ }
+ )
+ end
+ end
+
+ describe 'caching behavior' do
+ let!(:points) do
+ create_list(:point, 2,
+ user: user,
+ import: import,
+ reverse_geocoded_at: Time.current,
+ geodata: { 'address' => 'Test Address' })
+ end
+
+ it 'caches the geocoded stats' do
+ expect(Rails.cache).to receive(:fetch).with(
+ "dawarich/user_#{user.id}_points_geocoded_stats",
+ expires_in: 1.day
+ ).and_call_original
+
+ points_stats
+ end
+
+ it 'returns cached results on subsequent calls' do
+ # First call - should hit database and cache
+ expect(Point.connection).to receive(:select_one).once.and_call_original
+ first_result = points_stats
+
+ # Second call - should use cache, not hit database
+ expect(Point.connection).not_to receive(:select_one)
+ second_result = points_stats
+
+ expect(first_result).to eq(second_result)
+ end
+
+ it 'uses counter cache for total count' do
+ # Ensure counter cache is set correctly
+ user.reload
+ expect(user.points_count).to eq(2)
+
+ # The total should come from counter cache, not from SQL
+ result = points_stats
+ expect(result[:total]).to eq(user.points_count)
end
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/requests/api/v1/areas_spec.rb b/spec/requests/api/v1/areas_spec.rb
index 7be57513..c5f15948 100644
--- a/spec/requests/api/v1/areas_spec.rb
+++ b/spec/requests/api/v1/areas_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe '/api/v1/areas', type: :request do
post api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: invalid_attributes }
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
end
end
@@ -85,7 +85,7 @@ RSpec.describe '/api/v1/areas', type: :request do
patch api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" },
params: { area: invalid_attributes }
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
end
end
diff --git a/spec/requests/api/v1/maps/hexagons_spec.rb b/spec/requests/api/v1/maps/hexagons_spec.rb
new file mode 100644
index 00000000..f3750cf8
--- /dev/null
+++ b/spec/requests/api/v1/maps/hexagons_spec.rb
@@ -0,0 +1,267 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
+ let(:user) { create(:user) }
+
+ before do
+ stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
+ .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
+ end
+
+ describe 'GET /api/v1/maps/hexagons' do
+ let(:valid_params) do
+ {
+ min_lon: -74.1,
+ min_lat: 40.6,
+ max_lon: -73.9,
+ max_lat: 40.8,
+ hex_size: 1000,
+ start_date: '2024-06-01T00:00:00Z',
+ end_date: '2024-06-30T23:59:59Z'
+ }
+ end
+
+ context 'with valid API key authentication' do
+ let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
+
+ before do
+ # Create test points within the date range and bounding box
+ 10.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.001), # Slightly different coordinates
+ longitude: -74.0 + (i * 0.001),
+ timestamp: Time.new(2024, 6, 15, 12, i).to_i) # Different times
+ end
+ end
+
+ it 'returns hexagon data successfully' do
+ get '/api/v1/maps/hexagons', params: valid_params, headers: headers
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to have_key('type')
+ expect(json_response['type']).to eq('FeatureCollection')
+ expect(json_response).to have_key('features')
+ expect(json_response['features']).to be_an(Array)
+ end
+
+ it 'requires all bbox parameters' do
+ incomplete_params = valid_params.except(:min_lon)
+
+ get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers
+
+ expect(response).to have_http_status(:bad_request)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to include('Missing required parameters')
+ expect(json_response['error']).to include('min_lon')
+ end
+
+ it 'handles service validation errors' do
+ invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
+
+ get '/api/v1/maps/hexagons', params: invalid_params, headers: headers
+
+ expect(response).to have_http_status(:bad_request)
+ end
+
+ it 'uses custom hex_size when provided' do
+ custom_params = valid_params.merge(hex_size: 500)
+
+ get '/api/v1/maps/hexagons', params: custom_params, headers: headers
+
+ expect(response).to have_http_status(:success)
+ end
+ end
+
+ context 'with public sharing UUID' do
+ let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
+ let(:uuid_params) { valid_params.merge(uuid: stat.sharing_uuid) }
+
+ before do
+ # Create test points within the stat's month
+ 15.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.002),
+ longitude: -74.0 + (i * 0.002),
+ timestamp: Time.new(2024, 6, 20, 10, i).to_i)
+ end
+ end
+
+ it 'returns hexagon data without API key' do
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to have_key('type')
+ expect(json_response['type']).to eq('FeatureCollection')
+ expect(json_response).to have_key('features')
+ end
+
+ it 'uses stat date range automatically' do
+ # Points outside the stat's month should not be included
+ 5.times do |i|
+ create(:point,
+ user:,
+ latitude: 40.7 + (i * 0.003),
+ longitude: -74.0 + (i * 0.003),
+ timestamp: Time.new(2024, 7, 1, 8, i).to_i) # July points
+ end
+
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:success)
+ end
+
+ context 'with invalid sharing UUID' do
+ it 'returns not found' do
+ invalid_uuid_params = valid_params.merge(uuid: 'invalid-uuid')
+
+ get '/api/v1/maps/hexagons', params: invalid_uuid_params
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with expired sharing' do
+ let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) }
+
+ it 'returns not found' do
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with disabled sharing' do
+ let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) }
+
+ it 'returns not found' do
+ get '/api/v1/maps/hexagons', params: uuid_params
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+ end
+
+ context 'without authentication' do
+ it 'returns unauthorized' do
+ get '/api/v1/maps/hexagons', params: valid_params
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'with invalid API key' do
+ let(:headers) { { 'Authorization' => 'Bearer invalid-key' } }
+
+ it 'returns unauthorized' do
+ get '/api/v1/maps/hexagons', params: valid_params, headers: headers
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+
+ describe 'GET /api/v1/maps/hexagons/bounds' do
+ context 'with valid API key authentication' do
+ let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } }
+ let(:date_params) do
+ {
+ start_date: Time.new(2024, 6, 1).to_i,
+ end_date: Time.new(2024, 6, 30, 23, 59, 59).to_i
+ }
+ end
+
+ before do
+ # Create test points within the date range
+ create(:point, user:, latitude: 40.6, longitude: -74.1, timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
+ create(:point, user:, latitude: 40.8, longitude: -73.9, timestamp: Time.new(2024, 6, 30, 15, 0).to_i)
+ create(:point, user:, latitude: 40.7, longitude: -74.0, timestamp: Time.new(2024, 6, 15, 10, 0).to_i)
+ end
+
+ it 'returns bounding box for user data' do
+ get '/api/v1/maps/hexagons/bounds', params: date_params, headers: headers
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
+ expect(json_response['min_lat']).to eq(40.6)
+ expect(json_response['max_lat']).to eq(40.8)
+ expect(json_response['min_lng']).to eq(-74.1)
+ expect(json_response['max_lng']).to eq(-73.9)
+ expect(json_response['point_count']).to eq(3)
+ end
+
+ it 'returns not found when no points exist in date range' do
+ get '/api/v1/maps/hexagons/bounds',
+ params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' },
+ headers: headers
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('No data found for the specified date range')
+ expect(json_response['point_count']).to eq(0)
+ end
+ end
+
+ context 'with public sharing UUID' do
+ let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
+
+ before do
+ # Create test points within the stat's month
+ create(:point, user:, latitude: 41.0, longitude: -74.5, timestamp: Time.new(2024, 6, 5, 9, 0).to_i)
+ create(:point, user:, latitude: 41.2, longitude: -74.2, timestamp: Time.new(2024, 6, 25, 14, 0).to_i)
+ end
+
+ it 'returns bounds for the shared stat period' do
+ get '/api/v1/maps/hexagons/bounds', params: { uuid: stat.sharing_uuid }
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
+ expect(json_response['min_lat']).to eq(41.0)
+ expect(json_response['max_lat']).to eq(41.2)
+ expect(json_response['point_count']).to eq(2)
+ end
+
+ context 'with invalid sharing UUID' do
+ it 'returns not found' do
+ get '/api/v1/maps/hexagons/bounds', params: { uuid: 'invalid-uuid' }
+
+ expect(response).to have_http_status(:not_found)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['error']).to eq('Shared stats not found or no longer available')
+ end
+ end
+ end
+
+ context 'without authentication' do
+ it 'returns unauthorized' do
+ get '/api/v1/maps/hexagons/bounds',
+ params: { start_date: '2024-06-01T00:00:00Z', end_date: '2024-06-30T23:59:59Z' }
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/v1/settings_spec.rb b/spec/requests/api/v1/settings_spec.rb
index 3f6673e5..470563b7 100644
--- a/spec/requests/api/v1/settings_spec.rb
+++ b/spec/requests/api/v1/settings_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe 'Api::V1::Settings', type: :request do
it 'returns http unprocessable entity' do
patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 'invalid' } }
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
it 'returns an error message' do
diff --git a/spec/requests/api/v1/subscriptions_spec.rb b/spec/requests/api/v1/subscriptions_spec.rb
index 85e657e4..a034843e 100644
--- a/spec/requests/api/v1/subscriptions_spec.rb
+++ b/spec/requests/api/v1/subscriptions_spec.rb
@@ -96,13 +96,13 @@ RSpec.describe 'Api::V1::Subscriptions', type: :request do
JWT.encode({ user_id: 'invalid', status: nil }, jwt_secret, 'HS256')
end
- it 'returns unprocessable_entity error with invalid data message' do
+ it 'returns unprocessable_content error with invalid data message' do
allow(Subscription::DecodeJwtToken).to receive(:new).with(token)
.and_raise(ArgumentError.new('Invalid token data'))
post '/api/v1/subscriptions/callback', params: { token: token }
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
expect(JSON.parse(response.body)['message']).to eq('Invalid subscription data received.')
end
end
diff --git a/spec/requests/api/v1/visits_spec.rb b/spec/requests/api/v1/visits_spec.rb
index a250635c..d5e9317a 100644
--- a/spec/requests/api/v1/visits_spec.rb
+++ b/spec/requests/api/v1/visits_spec.rb
@@ -81,9 +81,9 @@ RSpec.describe 'Api::V1::Visits', type: :request do
let(:existing_place) { create(:place, latitude: 52.52, longitude: 13.405) }
it 'creates a new visit' do
- expect {
+ expect do
post '/api/v1/visits', params: valid_create_params, headers: auth_headers
- }.to change { user.visits.count }.by(1)
+ end.to change { user.visits.count }.by(1)
expect(response).to have_http_status(:ok)
end
@@ -100,9 +100,9 @@ RSpec.describe 'Api::V1::Visits', type: :request do
end
it 'creates a place for the visit' do
- expect {
+ expect do
post '/api/v1/visits', params: valid_create_params, headers: auth_headers
- }.to change { Place.count }.by(1)
+ end.to change { Place.count }.by(1)
created_place = Place.last
expect(created_place.name).to eq('Test Visit')
@@ -114,9 +114,9 @@ RSpec.describe 'Api::V1::Visits', type: :request do
it 'reuses existing place when coordinates are exactly the same' do
create(:visit, user: user, place: existing_place)
- expect {
+ expect do
post '/api/v1/visits', params: valid_create_params, headers: auth_headers
- }.not_to change { Place.count }
+ end.not_to(change { Place.count })
json_response = JSON.parse(response.body)
expect(json_response['place']['id']).to eq(existing_place.id)
@@ -132,7 +132,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do
it 'returns unprocessable entity status' do
post '/api/v1/visits', params: missing_name_params, headers: auth_headers
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
it 'returns error message' do
@@ -144,9 +144,9 @@ RSpec.describe 'Api::V1::Visits', type: :request do
end
it 'does not create a visit' do
- expect {
+ expect do
post '/api/v1/visits', params: missing_name_params, headers: auth_headers
- }.not_to change { Visit.count }
+ end.not_to(change { Visit.count })
end
end
end
@@ -199,7 +199,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do
it 'renders a JSON response with errors for the visit' do
put "/api/v1/visits/#{visit.id}", params: invalid_attributes, headers: auth_headers
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
end
end
@@ -234,7 +234,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do
it 'returns an error when fewer than 2 visits are specified' do
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id] }, headers: auth_headers
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
json_response = JSON.parse(response.body)
expect(json_response['error']).to include('At least 2 visits must be selected')
end
@@ -264,7 +264,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, visit2.id] }, headers: auth_headers
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
json_response = JSON.parse(response.body)
expect(json_response['error']).to include('Failed to merge visits')
end
@@ -316,7 +316,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do
post '/api/v1/visits/bulk_update', params: invalid_update_params, headers: auth_headers
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
json_response = JSON.parse(response.body)
expect(json_response['error']).to include('Invalid status')
end
@@ -329,9 +329,9 @@ RSpec.describe 'Api::V1::Visits', type: :request do
context 'when visit exists and belongs to current user' do
it 'deletes the visit' do
- expect {
+ expect do
delete "/api/v1/visits/#{visit.id}", headers: auth_headers
- }.to change { user.visits.count }.by(-1)
+ end.to change { user.visits.count }.by(-1)
expect(response).to have_http_status(:no_content)
end
@@ -363,9 +363,9 @@ RSpec.describe 'Api::V1::Visits', type: :request do
end
it 'does not delete the visit' do
- expect {
+ expect do
delete "/api/v1/visits/#{other_user_visit.id}", headers: auth_headers
- }.not_to change { Visit.count }
+ end.not_to(change { Visit.count })
end
end
diff --git a/spec/requests/exports_spec.rb b/spec/requests/exports_spec.rb
index 89658348..8fd9f43c 100644
--- a/spec/requests/exports_spec.rb
+++ b/spec/requests/exports_spec.rb
@@ -70,7 +70,7 @@ RSpec.describe '/exports', type: :request do
it 'renders a response with 422 status (i.e. to display the "new" template)' do
post(exports_url, params:)
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
end
end
diff --git a/spec/requests/imports_spec.rb b/spec/requests/imports_spec.rb
index 56eb3333..09481269 100644
--- a/spec/requests/imports_spec.rb
+++ b/spec/requests/imports_spec.rb
@@ -62,9 +62,10 @@ RSpec.describe 'Imports', type: :request do
end
it 'prevents viewing other users import' do
- expect {
- get import_path(other_import)
- }.to raise_error(Pundit::NotAuthorizedError)
+ get import_path(other_import)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
@@ -98,9 +99,10 @@ RSpec.describe 'Imports', type: :request do
end
it 'prevents access to new import form' do
- expect {
- get new_import_path
- }.to raise_error(Pundit::NotAuthorizedError)
+ get new_import_path
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
diff --git a/spec/requests/settings/background_jobs_spec.rb b/spec/requests/settings/background_jobs_spec.rb
index 64b415af..f2bea2cd 100644
--- a/spec/requests/settings/background_jobs_spec.rb
+++ b/spec/requests/settings/background_jobs_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
@@ -32,7 +32,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
context 'when job name is start_immich_import' do
@@ -104,7 +104,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
@@ -118,7 +118,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
context 'when user is an admin' do
@@ -128,7 +128,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
end
@@ -138,7 +138,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
post settings_background_jobs_url, params: { job_name: 'start_reverse_geocoding' }
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
context 'when job name is start_immich_import' do
@@ -146,7 +146,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
post settings_background_jobs_url, params: { job_name: 'start_immich_import' }
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
@@ -155,7 +155,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
post settings_background_jobs_url, params: { job_name: 'start_photoprism_import' }
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
@@ -166,7 +166,7 @@ RSpec.describe '/settings/background_jobs', type: :request do
get settings_background_jobs_url
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
end
diff --git a/spec/requests/settings/users_spec.rb b/spec/requests/settings/users_spec.rb
index 51079587..b8bc5a38 100644
--- a/spec/requests/settings/users_spec.rb
+++ b/spec/requests/settings/users_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe '/settings/users', type: :request do
it 'redirects to sign in page' do
post settings_users_url, params: { user: valid_attributes }
- expect(response).to redirect_to(root_url)
+ expect(response).to redirect_to(new_user_session_path)
end
end
@@ -64,7 +64,7 @@ RSpec.describe '/settings/users', type: :request do
it 'renders a response with 422 status (i.e. to display the "new" template)' do
post settings_users_url, params: { user: invalid_attributes }
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
end
end
@@ -101,7 +101,7 @@ RSpec.describe '/settings/users', type: :request do
get settings_users_url
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
@@ -110,7 +110,7 @@ RSpec.describe '/settings/users', type: :request do
post settings_users_url, params: { user: valid_attributes }
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
@@ -121,7 +121,7 @@ RSpec.describe '/settings/users', type: :request do
patch settings_user_url(user), params: { user: valid_attributes }
expect(response).to redirect_to(root_url)
- expect(flash[:notice]).to eq('You are not authorized to perform this action.')
+ expect(flash[:alert]).to eq('You are not authorized to perform this action.')
end
end
end
diff --git a/spec/requests/shared/stats_spec.rb b/spec/requests/shared/stats_spec.rb
new file mode 100644
index 00000000..cf711b54
--- /dev/null
+++ b/spec/requests/shared/stats_spec.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Shared::Stats', type: :request do
+ before do
+ stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
+ .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
+ end
+
+ context 'public sharing' do
+ let(:user) { create(:user) }
+ let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) }
+
+ describe 'GET /shared/stats/:uuid' do
+ context 'with valid sharing UUID' do
+ before do
+ # Create some test points for data bounds calculation
+ create_list(:point, 5, user:, timestamp: Time.new(2024, 6, 15).to_i)
+ end
+
+ it 'renders the public month view' do
+ get shared_stat_url(stat.sharing_uuid)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Monthly Digest')
+ expect(response.body).to include('June 2024')
+ end
+
+ it 'includes required content in response' do
+ get shared_stat_url(stat.sharing_uuid)
+
+ expect(response.body).to include('June 2024')
+ expect(response.body).to include('Monthly Digest')
+ expect(response.body).to include('data-public-stat-map-uuid-value')
+ expect(response.body).to include(stat.sharing_uuid)
+ end
+ end
+
+ context 'with invalid sharing UUID' do
+ it 'redirects to root with alert' do
+ get shared_stat_url('invalid-uuid')
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with expired sharing' do
+ let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) }
+
+ it 'redirects to root with alert' do
+ get shared_stat_url(stat.sharing_uuid)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'with disabled sharing' do
+ let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) }
+
+ it 'redirects to root with alert' do
+ get shared_stat_url(stat.sharing_uuid)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared stats not found or no longer available')
+ end
+ end
+
+ context 'when stat has no points' do
+ it 'renders successfully' do
+ get shared_stat_url(stat.sharing_uuid)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Monthly Digest')
+ end
+ end
+ end
+
+ describe 'PATCH /stats/:year/:month/sharing' do
+ context 'when user is signed in' do
+ let!(:stat_to_share) { create(:stat, user:, year: 2024, month: 6) }
+
+ before { sign_in user }
+
+ context 'enabling sharing' do
+ it 'enables sharing and returns success' do
+ patch sharing_stats_path(year: 2024, month: 6),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be(true)
+ expect(json_response['sharing_url']).to be_present
+ expect(json_response['message']).to eq('Sharing enabled successfully')
+
+ stat_to_share.reload
+ expect(stat_to_share.sharing_enabled?).to be(true)
+ expect(stat_to_share.sharing_uuid).to be_present
+ end
+
+ it 'sets custom expiration when provided' do
+ patch sharing_stats_path(year: 2024, month: 6),
+ params: { enabled: '1', expiration: '1_week' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ stat_to_share.reload
+ expect(stat_to_share.sharing_enabled?).to be(true)
+ end
+ end
+
+ context 'disabling sharing' do
+ let!(:enabled_stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 7) }
+
+ it 'disables sharing and returns success' do
+ patch sharing_stats_path(year: 2024, month: 7),
+ params: { enabled: '0' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be(true)
+ expect(json_response['message']).to eq('Sharing disabled successfully')
+
+ enabled_stat.reload
+ expect(enabled_stat.sharing_enabled?).to be(false)
+ end
+ end
+
+ context 'when stat does not exist' do
+ it 'returns not found' do
+ patch sharing_stats_path(year: 2024, month: 12),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when user is not signed in' do
+ it 'returns unauthorized' do
+ patch sharing_stats_path(year: 2024, month: 6),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/trips_spec.rb b/spec/requests/trips_spec.rb
index af654048..53549905 100644
--- a/spec/requests/trips_spec.rb
+++ b/spec/requests/trips_spec.rb
@@ -114,7 +114,7 @@ RSpec.describe '/trips', type: :request do
it "renders a response with 422 status (i.e. to display the 'new' template)" do
post trips_url, params: { trip: invalid_attributes }
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
end
end
@@ -151,7 +151,7 @@ RSpec.describe '/trips', type: :request do
it 'renders a response with 422 status' do
patch trip_url(trip), params: { trip: invalid_attributes }
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response).to have_http_status(:unprocessable_content)
end
end
end
diff --git a/spec/services/cache/clean_spec.rb b/spec/services/cache/clean_spec.rb
new file mode 100644
index 00000000..1d0ee55c
--- /dev/null
+++ b/spec/services/cache/clean_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Cache::Clean do
+ before { Rails.cache.clear }
+
+ describe '.call' do
+ let!(:user1) { create(:user) }
+ let!(:user2) { create(:user) }
+ let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" }
+ let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" }
+ let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" }
+ let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" }
+ let(:user_1_countries_key) { "dawarich/user_#{user1.id}_countries" }
+ let(:user_2_countries_key) { "dawarich/user_#{user2.id}_countries" }
+ let(:user_1_cities_key) { "dawarich/user_#{user1.id}_cities" }
+ let(:user_2_cities_key) { "dawarich/user_#{user2.id}_cities" }
+
+ before do
+ # Set up cache entries that should be cleaned
+ Rails.cache.write('cache_jobs_scheduled', true)
+ Rails.cache.write(CheckAppVersion::VERSION_CACHE_KEY, '1.0.0')
+ Rails.cache.write(user_1_years_tracked_key, { 2023 => %w[Jan Feb] })
+ Rails.cache.write(user_2_years_tracked_key, { 2023 => %w[Mar Apr] })
+ Rails.cache.write(user_1_points_geocoded_stats_key, { geocoded: 5, without_data: 2 })
+ Rails.cache.write(user_2_points_geocoded_stats_key, { geocoded: 3, without_data: 1 })
+ end
+
+ it 'deletes control flag cache' do
+ expect(Rails.cache.exist?('cache_jobs_scheduled')).to be true
+
+ described_class.call
+
+ expect(Rails.cache.exist?('cache_jobs_scheduled')).to be false
+ end
+
+ it 'deletes version cache' do
+ expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be true
+
+ described_class.call
+
+ expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be false
+ end
+
+ it 'deletes years tracked cache for all users' do
+ expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true
+ expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true
+
+ described_class.call
+
+ expect(Rails.cache.exist?(user_1_years_tracked_key)).to be false
+ expect(Rails.cache.exist?(user_2_years_tracked_key)).to be false
+ end
+
+ it 'deletes points geocoded stats cache for all users' do
+ expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true
+ expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true
+
+ described_class.call
+
+ expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be false
+ expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be false
+ end
+
+ it 'deletes countries and cities cache for all users' do
+ Rails.cache.write(user_1_countries_key, %w[USA Canada])
+ Rails.cache.write(user_2_countries_key, %w[France Germany])
+ Rails.cache.write(user_1_cities_key, ['New York', 'Toronto'])
+ Rails.cache.write(user_2_cities_key, %w[Paris Berlin])
+
+ expect(Rails.cache.exist?(user_1_countries_key)).to be true
+ expect(Rails.cache.exist?(user_2_countries_key)).to be true
+ expect(Rails.cache.exist?(user_1_cities_key)).to be true
+ expect(Rails.cache.exist?(user_2_cities_key)).to be true
+
+ described_class.call
+
+ expect(Rails.cache.exist?(user_1_countries_key)).to be false
+ expect(Rails.cache.exist?(user_2_countries_key)).to be false
+ expect(Rails.cache.exist?(user_1_cities_key)).to be false
+ expect(Rails.cache.exist?(user_2_cities_key)).to be false
+ end
+
+ it 'logs cache cleaning process' do
+ expect(Rails.logger).to receive(:info).with('Cleaning cache...')
+ expect(Rails.logger).to receive(:info).with('Cache cleaned')
+
+ described_class.call
+ end
+
+ it 'handles users being added during execution gracefully' do
+ # Create a user that will be found during the cleaning process
+ user3 = nil
+
+ allow(User).to receive(:find_each).and_yield(user1).and_yield(user2) do |&block|
+ # Create a new user while iterating - this should not cause errors
+ user3 = create(:user)
+ Rails.cache.write("dawarich/user_#{user3.id}_years_tracked", { 2023 => ['May'] })
+ Rails.cache.write("dawarich/user_#{user3.id}_points_geocoded_stats", { geocoded: 1, without_data: 0 })
+
+ # Continue with the original block
+ [user1, user2].each(&block)
+ end
+
+ expect { described_class.call }.not_to raise_error
+
+ # The new user's cache should still exist since it wasn't processed
+ expect(Rails.cache.exist?("dawarich/user_#{user3.id}_years_tracked")).to be true
+ expect(Rails.cache.exist?("dawarich/user_#{user3.id}_points_geocoded_stats")).to be true
+ end
+ end
+end
diff --git a/spec/services/tracks/generator_spec.rb b/spec/services/tracks/generator_spec.rb
deleted file mode 100644
index 6f352b86..00000000
--- a/spec/services/tracks/generator_spec.rb
+++ /dev/null
@@ -1,260 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Tracks::Generator do
- let(:user) { create(:user) }
- let(:safe_settings) { user.safe_settings }
-
- before do
- allow(user).to receive(:safe_settings).and_return(safe_settings)
- end
-
- describe '#call' do
- context 'with bulk mode' do
- let(:generator) { described_class.new(user, mode: :bulk) }
-
- context 'with sufficient points' do
- let!(:points) { create_points_around(user: user, count: 5, base_lat: 20.0) }
-
- it 'generates tracks from all points' do
- expect { generator.call }.to change(Track, :count).by(1)
- end
-
- it 'cleans existing tracks' do
- existing_track = create(:track, user: user)
- generator.call
- expect(Track.exists?(existing_track.id)).to be false
- end
-
- it 'associates points with created tracks' do
- generator.call
- expect(points.map(&:reload).map(&:track)).to all(be_present)
- end
-
- it 'properly handles point associations when cleaning existing tracks' do
- # Create existing tracks with associated points
- existing_track = create(:track, user: user)
- existing_points = create_list(:point, 3, user: user, track: existing_track)
-
- # Verify points are associated
- expect(existing_points.map(&:reload).map(&:track_id)).to all(eq(existing_track.id))
-
- # Run generator which should clean existing tracks and create new ones
- generator.call
-
- # Verify the old track is deleted
- expect(Track.exists?(existing_track.id)).to be false
-
- # Verify the points are no longer associated with the deleted track
- expect(existing_points.map(&:reload).map(&:track_id)).to all(be_nil)
- end
- end
-
- context 'with insufficient points' do
- let!(:points) { create_points_around(user: user, count: 1, base_lat: 20.0) }
-
- it 'does not create tracks' do
- expect { generator.call }.not_to change(Track, :count)
- end
- end
-
- context 'with time range' do
- let!(:old_points) { create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 2.days.ago.to_i) }
- let!(:new_points) { create_points_around(user: user, count: 3, base_lat: 21.0, timestamp: 1.day.ago.to_i) }
-
- it 'only processes points within range' do
- generator = described_class.new(
- user,
- start_at: 1.day.ago.beginning_of_day,
- end_at: 1.day.ago.end_of_day,
- mode: :bulk
- )
-
- generator.call
- track = Track.last
- expect(track.points.count).to eq(3)
- end
- end
- end
-
- context 'with incremental mode' do
- let(:generator) { described_class.new(user, mode: :incremental) }
-
- context 'with untracked points' do
- let!(:points) { create_points_around(user: user, count: 3, base_lat: 22.0, track_id: nil) }
-
- it 'processes untracked points' do
- expect { generator.call }.to change(Track, :count).by(1)
- end
-
- it 'associates points with created tracks' do
- generator.call
- expect(points.map(&:reload).map(&:track)).to all(be_present)
- end
- end
-
- context 'with end_at specified' do
- let!(:early_points) { create_points_around(user: user, count: 2, base_lat: 23.0, timestamp: 2.hours.ago.to_i) }
- let!(:late_points) { create_points_around(user: user, count: 2, base_lat: 24.0, timestamp: 1.hour.ago.to_i) }
-
- it 'only processes points up to end_at' do
- generator = described_class.new(user, end_at: 1.5.hours.ago, mode: :incremental)
- generator.call
-
- expect(Track.count).to eq(1)
- expect(Track.first.points.count).to eq(2)
- end
- end
-
- context 'without existing tracks' do
- let!(:points) { create_points_around(user: user, count: 3, base_lat: 25.0) }
-
- it 'does not clean existing tracks' do
- existing_track = create(:track, user: user)
- generator.call
- expect(Track.exists?(existing_track.id)).to be true
- end
- end
- end
-
- context 'with daily mode' do
- let(:today) { Date.current }
- let(:generator) { described_class.new(user, start_at: today, mode: :daily) }
-
- let!(:today_points) { create_points_around(user: user, count: 3, base_lat: 26.0, timestamp: today.beginning_of_day.to_i) }
- let!(:yesterday_points) { create_points_around(user: user, count: 3, base_lat: 27.0, timestamp: 1.day.ago.to_i) }
-
- it 'only processes points from specified day' do
- generator.call
- track = Track.last
- expect(track.points.count).to eq(3)
- end
-
- it 'cleans existing tracks for the day' do
- existing_track = create(:track, user: user, start_at: today.beginning_of_day)
- generator.call
- expect(Track.exists?(existing_track.id)).to be false
- end
-
- it 'properly handles point associations when cleaning daily tracks' do
- # Create existing tracks with associated points for today
- existing_track = create(:track, user: user, start_at: today.beginning_of_day)
- existing_points = create_list(:point, 3, user: user, track: existing_track)
-
- # Verify points are associated
- expect(existing_points.map(&:reload).map(&:track_id)).to all(eq(existing_track.id))
-
- # Run generator which should clean existing tracks for the day and create new ones
- generator.call
-
- # Verify the old track is deleted
- expect(Track.exists?(existing_track.id)).to be false
-
- # Verify the points are no longer associated with the deleted track
- expect(existing_points.map(&:reload).map(&:track_id)).to all(be_nil)
- end
- end
-
- context 'with empty points' do
- let(:generator) { described_class.new(user, mode: :bulk) }
-
- it 'does not create tracks' do
- expect { generator.call }.not_to change(Track, :count)
- end
- end
-
- context 'with threshold configuration' do
- let(:generator) { described_class.new(user, mode: :bulk) }
-
- before do
- allow(safe_settings).to receive(:meters_between_routes).and_return(1000)
- allow(safe_settings).to receive(:minutes_between_routes).and_return(90)
- end
-
- it 'uses configured thresholds' do
- expect(generator.send(:distance_threshold_meters)).to eq(1000)
- expect(generator.send(:time_threshold_minutes)).to eq(90)
- end
- end
-
- context 'with invalid mode' do
- it 'raises argument error' do
- expect do
- described_class.new(user, mode: :invalid).call
- end.to raise_error(ArgumentError, /Unknown mode/)
- end
- end
- end
-
- describe 'segmentation behavior' do
- let(:generator) { described_class.new(user, mode: :bulk) }
-
- context 'with points exceeding time threshold' do
- let!(:points) do
- [
- create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 90.minutes.ago.to_i),
- create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 60.minutes.ago.to_i),
- # Gap exceeds threshold 👇👇👇
- create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 10.minutes.ago.to_i),
- create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: Time.current.to_i)
- ]
- end
-
- before do
- allow(safe_settings).to receive(:minutes_between_routes).and_return(45)
- end
-
- it 'creates separate tracks for segments' do
- expect { generator.call }.to change(Track, :count).by(2)
- end
- end
-
- context 'with points exceeding distance threshold' do
- let!(:points) do
- [
- create_points_around(user: user, count: 2, base_lat: 29.0, timestamp: 20.minutes.ago.to_i),
- create_points_around(user: user, count: 2, base_lat: 29.0, timestamp: 15.minutes.ago.to_i),
- # Large distance jump 👇👇👇
- create_points_around(user: user, count: 2, base_lat: 28.0, timestamp: 10.minutes.ago.to_i),
- create_points_around(user: user, count: 1, base_lat: 28.0, timestamp: Time.current.to_i)
- ]
- end
-
- before do
- allow(safe_settings).to receive(:meters_between_routes).and_return(200)
- end
-
- it 'creates separate tracks for segments' do
- expect { generator.call }.to change(Track, :count).by(2)
- end
- end
- end
-
- describe 'deterministic behavior' do
- let!(:points) { create_points_around(user: user, count: 10, base_lat: 28.0) }
-
- it 'produces same results for bulk and incremental modes' do
- # Generate tracks in bulk mode
- bulk_generator = described_class.new(user, mode: :bulk)
- bulk_generator.call
- bulk_tracks = user.tracks.order(:start_at).to_a
-
- # Clear tracks and generate incrementally
- user.tracks.destroy_all
- incremental_generator = described_class.new(user, mode: :incremental)
- incremental_generator.call
- incremental_tracks = user.tracks.order(:start_at).to_a
-
- # Should have same number of tracks
- expect(incremental_tracks.size).to eq(bulk_tracks.size)
-
- # Should have same track boundaries (allowing for small timing differences)
- bulk_tracks.zip(incremental_tracks).each do |bulk_track, incremental_track|
- expect(incremental_track.start_at).to be_within(1.second).of(bulk_track.start_at)
- expect(incremental_track.end_at).to be_within(1.second).of(bulk_track.end_at)
- expect(incremental_track.distance).to be_within(10).of(bulk_track.distance)
- end
- end
- end
-end
diff --git a/spec/services/tracks/incremental_processor_spec.rb b/spec/services/tracks/incremental_processor_spec.rb
deleted file mode 100644
index 165af52d..00000000
--- a/spec/services/tracks/incremental_processor_spec.rb
+++ /dev/null
@@ -1,249 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Tracks::IncrementalProcessor do
- let(:user) { create(:user) }
- let(:safe_settings) { user.safe_settings }
-
- before do
- allow(user).to receive(:safe_settings).and_return(safe_settings)
- allow(safe_settings).to receive(:minutes_between_routes).and_return(30)
- allow(safe_settings).to receive(:meters_between_routes).and_return(500)
- end
-
- describe '#call' do
- context 'with imported points' do
- let(:imported_point) { create(:point, user: user, import: create(:import)) }
- let(:processor) { described_class.new(user, imported_point) }
-
- it 'does not process imported points' do
- expect(Tracks::CreateJob).not_to receive(:perform_later)
-
- processor.call
- end
- end
-
- context 'with first point for user' do
- let(:new_point) { create(:point, user: user) }
- let(:processor) { described_class.new(user, new_point) }
-
- it 'processes first point' do
- expect(Tracks::CreateJob).to receive(:perform_later)
- .with(user.id, start_at: nil, end_at: nil, mode: :incremental)
- processor.call
- end
- end
-
- context 'with thresholds exceeded' do
- let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
- let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
- let(:processor) { described_class.new(user, new_point) }
-
- before do
- # Create previous point first
- previous_point
- end
-
- it 'processes when time threshold exceeded' do
- expect(Tracks::CreateJob).to receive(:perform_later)
- .with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
- processor.call
- end
- end
-
- context 'with existing tracks' do
- let(:existing_track) { create(:track, user: user, end_at: 2.hours.ago) }
- let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
- let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
- let(:processor) { described_class.new(user, new_point) }
-
- before do
- existing_track
- previous_point
- end
-
- it 'uses existing track end time as start_at' do
- expect(Tracks::CreateJob).to receive(:perform_later)
- .with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
- processor.call
- end
- end
-
- context 'with distance threshold exceeded' do
- let(:previous_point) do
- create(:point, user: user, timestamp: 10.minutes.ago.to_i, lonlat: 'POINT(0 0)')
- end
- let(:new_point) do
- create(:point, user: user, timestamp: Time.current.to_i, lonlat: 'POINT(1 1)')
- end
- let(:processor) { described_class.new(user, new_point) }
-
- before do
- # Create previous point first
- previous_point
- # Mock distance calculation to exceed threshold
- allow_any_instance_of(Point).to receive(:distance_to).and_return(1.0) # 1 km = 1000m
- end
-
- it 'processes when distance threshold exceeded' do
- expect(Tracks::CreateJob).to receive(:perform_later)
- .with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental)
- processor.call
- end
- end
-
- context 'with thresholds not exceeded' do
- let(:previous_point) { create(:point, user: user, timestamp: 10.minutes.ago.to_i) }
- let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
- let(:processor) { described_class.new(user, new_point) }
-
- before do
- # Create previous point first
- previous_point
- # Mock distance to be within threshold
- allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m
- end
-
- it 'does not process when thresholds not exceeded' do
- expect(Tracks::CreateJob).not_to receive(:perform_later)
- processor.call
- end
- end
- end
-
- describe '#should_process?' do
- let(:processor) { described_class.new(user, new_point) }
-
- context 'with imported point' do
- let(:new_point) { create(:point, user: user, import: create(:import)) }
-
- it 'returns false' do
- expect(processor.send(:should_process?)).to be false
- end
- end
-
- context 'with first point for user' do
- let(:new_point) { create(:point, user: user) }
-
- it 'returns true' do
- expect(processor.send(:should_process?)).to be true
- end
- end
-
- context 'with thresholds exceeded' do
- let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
- let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
-
- before do
- previous_point # Create previous point
- end
-
- it 'returns true when time threshold exceeded' do
- expect(processor.send(:should_process?)).to be true
- end
- end
-
- context 'with thresholds not exceeded' do
- let(:previous_point) { create(:point, user: user, timestamp: 10.minutes.ago.to_i) }
- let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
-
- before do
- previous_point # Create previous point
- allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m
- end
-
- it 'returns false when thresholds not exceeded' do
- expect(processor.send(:should_process?)).to be false
- end
- end
- end
-
- describe '#exceeds_thresholds?' do
- let(:processor) { described_class.new(user, new_point) }
- let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
- let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) }
-
- context 'with time threshold exceeded' do
- before do
- allow(safe_settings).to receive(:minutes_between_routes).and_return(30)
- end
-
- it 'returns true' do
- result = processor.send(:exceeds_thresholds?, previous_point, new_point)
- expect(result).to be true
- end
- end
-
- context 'with distance threshold exceeded' do
- before do
- allow(safe_settings).to receive(:minutes_between_routes).and_return(120) # 2 hours
- allow(safe_settings).to receive(:meters_between_routes).and_return(400)
- allow_any_instance_of(Point).to receive(:distance_to).and_return(0.5) # 500m
- end
-
- it 'returns true' do
- result = processor.send(:exceeds_thresholds?, previous_point, new_point)
- expect(result).to be true
- end
- end
-
- context 'with neither threshold exceeded' do
- before do
- allow(safe_settings).to receive(:minutes_between_routes).and_return(120) # 2 hours
- allow(safe_settings).to receive(:meters_between_routes).and_return(600)
- allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m
- end
-
- it 'returns false' do
- result = processor.send(:exceeds_thresholds?, previous_point, new_point)
- expect(result).to be false
- end
- end
- end
-
- describe '#time_difference_minutes' do
- let(:processor) { described_class.new(user, new_point) }
- let(:point1) { create(:point, user: user, timestamp: 1.hour.ago.to_i) }
- let(:point2) { create(:point, user: user, timestamp: Time.current.to_i) }
- let(:new_point) { point2 }
-
- it 'calculates time difference in minutes' do
- result = processor.send(:time_difference_minutes, point1, point2)
- expect(result).to be_within(1).of(60) # Approximately 60 minutes
- end
- end
-
- describe '#distance_difference_meters' do
- let(:processor) { described_class.new(user, new_point) }
- let(:point1) { create(:point, user: user) }
- let(:point2) { create(:point, user: user) }
- let(:new_point) { point2 }
-
- before do
- allow(point1).to receive(:distance_to).with(point2).and_return(1.5) # 1.5 km
- end
-
- it 'calculates distance difference in meters' do
- result = processor.send(:distance_difference_meters, point1, point2)
- expect(result).to eq(1500) # 1.5 km = 1500 m
- end
- end
-
- describe 'threshold configuration' do
- let(:processor) { described_class.new(user, create(:point, user: user)) }
-
- before do
- allow(safe_settings).to receive(:minutes_between_routes).and_return(45)
- allow(safe_settings).to receive(:meters_between_routes).and_return(750)
- end
-
- it 'uses configured time threshold' do
- expect(processor.send(:time_threshold_minutes)).to eq(45)
- end
-
- it 'uses configured distance threshold' do
- expect(processor.send(:distance_threshold_meters)).to eq(750)
- end
- end
-end
diff --git a/spec/services/tracks/parallel_generator_spec.rb b/spec/services/tracks/parallel_generator_spec.rb
index 26d89802..eebe107b 100644
--- a/spec/services/tracks/parallel_generator_spec.rb
+++ b/spec/services/tracks/parallel_generator_spec.rb
@@ -80,15 +80,15 @@ RSpec.describe Tracks::ParallelGenerator do
end
it 'enqueues time chunk processor jobs' do
- expect {
+ expect do
generator.call
- }.to have_enqueued_job(Tracks::TimeChunkProcessorJob).at_least(:once)
+ end.to have_enqueued_job(Tracks::TimeChunkProcessorJob).at_least(:once)
end
it 'enqueues boundary resolver job with delay' do
- expect {
+ expect do
generator.call
- }.to have_enqueued_job(Tracks::BoundaryResolverJob).at(be >= 5.minutes.from_now)
+ end.to have_enqueued_job(Tracks::BoundaryResolverJob).at(be >= 5.minutes.from_now)
end
it 'logs the operation' do
@@ -108,9 +108,9 @@ RSpec.describe Tracks::ParallelGenerator do
end
it 'does not enqueue any jobs' do
- expect {
+ expect do
generator.call
- }.not_to have_enqueued_job
+ end.not_to have_enqueued_job
end
end
@@ -133,14 +133,14 @@ RSpec.describe Tracks::ParallelGenerator do
context 'daily mode' do
let(:options) { { mode: :daily, start_at: 1.day.ago.beginning_of_day } }
- it 'cleans tracks for the specific day' do
+ it 'preserves existing tracks' do
expect(user.tracks.count).to eq(2)
generator.call
- # Should only clean tracks from the specified day
+ # Daily mode should preserve all existing tracks
remaining_tracks = user.tracks.count
- expect(remaining_tracks).to be < 2
+ expect(remaining_tracks).to eq(2)
end
end
@@ -161,8 +161,8 @@ RSpec.describe Tracks::ParallelGenerator do
let(:start_time) { 3.days.ago }
let(:end_time) { 1.day.ago }
let(:options) { { start_at: start_time, end_at: end_time, mode: :bulk } }
- let!(:track_in_range) { create(:track, user: user, start_at: 2.days.ago) }
- let!(:track_out_of_range) { create(:track, user: user, start_at: 1.week.ago) }
+ let!(:track_in_range) { create(:track, user: user, start_at: 2.days.ago, end_at: 2.days.ago + 1.hour) }
+ let!(:track_out_of_range) { create(:track, user: user, start_at: 1.week.ago, end_at: 1.week.ago + 1.hour) }
it 'only cleans tracks within the specified range' do
expect(user.tracks.count).to eq(2)
@@ -191,37 +191,21 @@ RSpec.describe Tracks::ParallelGenerator do
create(:point, user: user, timestamp: (10 - i).days.ago.to_i)
end
- expect {
+ expect do
generator.call
- }.to have_enqueued_job(Tracks::BoundaryResolverJob)
+ end.to have_enqueued_job(Tracks::BoundaryResolverJob)
.with(user.id, kind_of(String))
end
it 'ensures minimum delay for boundary resolver' do
# Even with few chunks, should have minimum delay
- expect {
+ expect do
generator.call
- }.to have_enqueued_job(Tracks::BoundaryResolverJob)
+ end.to have_enqueued_job(Tracks::BoundaryResolverJob)
.at(be >= 5.minutes.from_now)
end
end
- context 'error handling in private methods' do
- it 'handles unknown mode in should_clean_tracks?' do
- generator.instance_variable_set(:@mode, :unknown)
-
- expect(generator.send(:should_clean_tracks?)).to be false
- end
-
- it 'raises error for unknown mode in clean_existing_tracks' do
- generator.instance_variable_set(:@mode, :unknown)
-
- expect {
- generator.send(:clean_existing_tracks)
- }.to raise_error(ArgumentError, 'Unknown mode: unknown')
- end
- end
-
context 'user settings integration' do
let(:mock_settings) { double('SafeSettings') }
@@ -277,15 +261,17 @@ RSpec.describe Tracks::ParallelGenerator do
describe '#enqueue_chunk_jobs' do
let(:session_id) { 'test-session' }
- let(:chunks) { [
- { chunk_id: 'chunk1', start_timestamp: 1.day.ago.to_i },
- { chunk_id: 'chunk2', start_timestamp: 2.days.ago.to_i }
- ] }
+ let(:chunks) do
+ [
+ { chunk_id: 'chunk1', start_timestamp: 1.day.ago.to_i },
+ { chunk_id: 'chunk2', start_timestamp: 2.days.ago.to_i }
+ ]
+ end
it 'enqueues job for each chunk' do
- expect {
+ expect do
generator.send(:enqueue_chunk_jobs, session_id, chunks)
- }.to have_enqueued_job(Tracks::TimeChunkProcessorJob)
+ end.to have_enqueued_job(Tracks::TimeChunkProcessorJob)
.exactly(2).times
end
@@ -303,24 +289,24 @@ RSpec.describe Tracks::ParallelGenerator do
let(:session_id) { 'test-session' }
it 'enqueues boundary resolver with estimated delay' do
- expect {
+ expect do
generator.send(:enqueue_boundary_resolver, session_id, 5)
- }.to have_enqueued_job(Tracks::BoundaryResolverJob)
+ end.to have_enqueued_job(Tracks::BoundaryResolverJob)
.with(user.id, session_id)
.at(be >= 2.minutes.from_now)
end
it 'uses minimum delay for small chunk counts' do
- expect {
+ expect do
generator.send(:enqueue_boundary_resolver, session_id, 1)
- }.to have_enqueued_job(Tracks::BoundaryResolverJob)
+ end.to have_enqueued_job(Tracks::BoundaryResolverJob)
.at(be >= 5.minutes.from_now)
end
it 'scales delay with chunk count' do
- expect {
+ expect do
generator.send(:enqueue_boundary_resolver, session_id, 20)
- }.to have_enqueued_job(Tracks::BoundaryResolverJob)
+ end.to have_enqueued_job(Tracks::BoundaryResolverJob)
.at(be >= 10.minutes.from_now)
end
end
diff --git a/spec/services/tracks/session_manager_spec.rb b/spec/services/tracks/session_manager_spec.rb
index 61f5a1df..aefc55f7 100644
--- a/spec/services/tracks/session_manager_spec.rb
+++ b/spec/services/tracks/session_manager_spec.rb
@@ -28,10 +28,10 @@ RSpec.describe Tracks::SessionManager do
it 'creates a new session with default values' do
result = manager.create_session(metadata)
-
+
expect(result).to eq(manager)
expect(manager.session_exists?).to be true
-
+
session_data = manager.get_session_data
expect(session_data['status']).to eq('pending')
expect(session_data['total_chunks']).to eq(0)
@@ -45,7 +45,7 @@ RSpec.describe Tracks::SessionManager do
it 'sets TTL on the cache entry' do
manager.create_session(metadata)
-
+
# Check that the key exists and will expire
expect(Rails.cache.exist?(manager.send(:cache_key))).to be true
end
@@ -59,7 +59,7 @@ RSpec.describe Tracks::SessionManager do
it 'returns session data when session exists' do
metadata = { test: 'data' }
manager.create_session(metadata)
-
+
data = manager.get_session_data
expect(data).to be_a(Hash)
expect(data['metadata']).to eq(metadata.deep_stringify_keys)
@@ -85,9 +85,9 @@ RSpec.describe Tracks::SessionManager do
it 'updates existing session data' do
updates = { status: 'processing', total_chunks: 5 }
result = manager.update_session(updates)
-
+
expect(result).to be true
-
+
data = manager.get_session_data
expect(data['status']).to eq('processing')
expect(data['total_chunks']).to eq(5)
@@ -96,7 +96,7 @@ RSpec.describe Tracks::SessionManager do
it 'returns false when session does not exist' do
manager.cleanup_session
result = manager.update_session({ status: 'processing' })
-
+
expect(result).to be false
end
@@ -104,9 +104,9 @@ RSpec.describe Tracks::SessionManager do
original_metadata = { mode: 'bulk' }
manager.cleanup_session
manager.create_session(original_metadata)
-
+
manager.update_session({ status: 'processing' })
-
+
data = manager.get_session_data
expect(data['metadata']).to eq(original_metadata.stringify_keys)
expect(data['status']).to eq('processing')
@@ -120,9 +120,9 @@ RSpec.describe Tracks::SessionManager do
it 'marks session as processing with total chunks' do
result = manager.mark_started(10)
-
+
expect(result).to be true
-
+
data = manager.get_session_data
expect(data['status']).to eq('processing')
expect(data['total_chunks']).to eq(10)
@@ -137,9 +137,9 @@ RSpec.describe Tracks::SessionManager do
end
it 'increments completed chunks counter' do
- expect {
+ expect do
manager.increment_completed_chunks
- }.to change {
+ end.to change {
manager.get_session_data['completed_chunks']
}.from(0).to(1)
end
@@ -156,17 +156,17 @@ RSpec.describe Tracks::SessionManager do
end
it 'increments tracks created counter by 1 by default' do
- expect {
+ expect do
manager.increment_tracks_created
- }.to change {
+ end.to change {
manager.get_session_data['tracks_created']
}.from(0).to(1)
end
it 'increments tracks created counter by specified amount' do
- expect {
+ expect do
manager.increment_tracks_created(5)
- }.to change {
+ end.to change {
manager.get_session_data['tracks_created']
}.from(0).to(5)
end
@@ -184,9 +184,9 @@ RSpec.describe Tracks::SessionManager do
it 'marks session as completed with timestamp' do
result = manager.mark_completed
-
+
expect(result).to be true
-
+
data = manager.get_session_data
expect(data['status']).to eq('completed')
expect(data['completed_at']).to be_present
@@ -200,11 +200,11 @@ RSpec.describe Tracks::SessionManager do
it 'marks session as failed with error message and timestamp' do
error_message = 'Something went wrong'
-
+
result = manager.mark_failed(error_message)
-
+
expect(result).to be true
-
+
data = manager.get_session_data
expect(data['status']).to eq('failed')
expect(data['error_message']).to eq(error_message)
@@ -239,35 +239,6 @@ RSpec.describe Tracks::SessionManager do
end
end
- describe '#progress_percentage' do
- before do
- manager.create_session
- end
-
- it 'returns 0 when session does not exist' do
- manager.cleanup_session
- expect(manager.progress_percentage).to eq(0)
- end
-
- it 'returns 100 when total chunks is 0' do
- expect(manager.progress_percentage).to eq(100)
- end
-
- it 'calculates correct percentage' do
- manager.mark_started(4)
- 2.times { manager.increment_completed_chunks }
-
- expect(manager.progress_percentage).to eq(50.0)
- end
-
- it 'rounds to 2 decimal places' do
- manager.mark_started(3)
- manager.increment_completed_chunks
-
- expect(manager.progress_percentage).to eq(33.33)
- end
- end
-
describe '#cleanup_session' do
before do
manager.create_session
@@ -275,9 +246,9 @@ RSpec.describe Tracks::SessionManager do
it 'removes session from cache' do
expect(manager.session_exists?).to be true
-
+
manager.cleanup_session
-
+
expect(manager.session_exists?).to be false
end
end
@@ -287,53 +258,30 @@ RSpec.describe Tracks::SessionManager do
it 'creates and returns a session manager' do
result = described_class.create_for_user(user_id, metadata)
-
+
expect(result).to be_a(described_class)
expect(result.user_id).to eq(user_id)
expect(result.session_exists?).to be true
-
+
data = result.get_session_data
expect(data['metadata']).to eq(metadata.deep_stringify_keys)
end
end
- describe '.find_session' do
- it 'returns nil when session does not exist' do
- result = described_class.find_session(user_id, 'non-existent')
- expect(result).to be_nil
- end
-
- it 'returns session manager when session exists' do
- manager.create_session
-
- result = described_class.find_session(user_id, session_id)
-
- expect(result).to be_a(described_class)
- expect(result.user_id).to eq(user_id)
- expect(result.session_id).to eq(session_id)
- end
- end
-
- describe '.cleanup_expired_sessions' do
- it 'returns true (no-op with Rails.cache TTL)' do
- expect(described_class.cleanup_expired_sessions).to be true
- end
- end
-
describe 'cache key scoping' do
it 'uses user-scoped cache keys' do
expected_key = "track_generation:user:#{user_id}:session:#{session_id}"
actual_key = manager.send(:cache_key)
-
+
expect(actual_key).to eq(expected_key)
end
it 'prevents cross-user session access' do
manager.create_session
other_manager = described_class.new(999, session_id)
-
+
expect(manager.session_exists?).to be true
expect(other_manager.session_exists?).to be false
end
end
-end
\ No newline at end of file
+end
diff --git a/spec/services/users/export_import_integration_spec.rb b/spec/services/users/export_import_integration_spec.rb
index 66e3c6a0..2be18ee7 100644
--- a/spec/services/users/export_import_integration_spec.rb
+++ b/spec/services/users/export_import_integration_spec.rb
@@ -5,7 +5,7 @@ require 'rails_helper'
RSpec.describe 'Users Export-Import Integration', type: :service do
let(:original_user) { create(:user, email: 'original@example.com') }
let(:target_user) { create(:user, email: 'target@example.com') }
- let(:temp_archive_path) { Rails.root.join('tmp', 'test_export.zip') }
+ let(:temp_archive_path) { Rails.root.join('tmp/test_export.zip') }
after do
File.delete(temp_archive_path) if File.exist?(temp_archive_path)
@@ -40,17 +40,12 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
Rails.logger.level = original_log_level
end
- puts "Import stats: #{import_stats.inspect}"
-
user_notifications_count = original_user.notifications.where.not(
title: ['Data import completed', 'Data import failed', 'Export completed', 'Export failed']
).count
target_counts = calculate_user_entity_counts(target_user)
- puts "Original counts: #{original_counts.inspect}"
- puts "Target counts: #{target_counts.inspect}"
-
expect(target_counts[:areas]).to eq(original_counts[:areas])
expect(target_counts[:imports]).to eq(original_counts[:imports])
expect(target_counts[:exports]).to eq(original_counts[:exports])
@@ -184,18 +179,22 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
import_stats = import_service.import
# Verify all entities were imported correctly
- expect(import_stats[:places_created]).to eq(original_places_count),
+ expect(import_stats[:places_created]).to \
+ eq(original_places_count),
"Expected #{original_places_count} places to be created, got #{import_stats[:places_created]}"
- expect(import_stats[:visits_created]).to eq(original_visits_count),
+ expect(import_stats[:visits_created]).to \
+ eq(original_visits_count),
"Expected #{original_visits_count} visits to be created, got #{import_stats[:visits_created]}"
# Verify the imported user has access to all their data
imported_places_count = import_user.places.distinct.count
imported_visits_count = import_user.visits.count
- expect(imported_places_count).to eq(original_places_count),
+ expect(imported_places_count).to \
+ eq(original_places_count),
"Expected user to have access to #{original_places_count} places, got #{imported_places_count}"
- expect(imported_visits_count).to eq(original_visits_count),
+ expect(imported_visits_count).to \
+ eq(original_visits_count),
"Expected user to have #{original_visits_count} visits, got #{imported_visits_count}"
# Verify specific visits have their place associations
@@ -205,7 +204,7 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
# Verify place names are preserved
place_names = visits_with_places.map { |v| v.place.name }.sort
- expect(place_names).to eq(['Gym', 'Home', 'Office'])
+ expect(place_names).to eq(%w[Gym Home Office])
# Cleanup
temp_export_file.unlink
@@ -216,12 +215,13 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
private
def create_full_user_dataset(user)
- user.update!(settings: {
- 'distance_unit' => 'km',
- 'timezone' => 'America/New_York',
- 'immich_url' => 'https://immich.example.com',
- 'immich_api_key' => 'test-api-key'
- })
+ user.update!(settings:
+ {
+ 'distance_unit' => 'km',
+ 'timezone' => 'America/New_York',
+ 'immich_url' => 'https://immich.example.com',
+ 'immich_api_key' => 'test-api-key'
+ })
usa = create(:country, name: 'United States', iso_a2: 'US', iso_a3: 'USA')
canada = create(:country, name: 'Canada', iso_a2: 'CA', iso_a3: 'CAN')
@@ -271,37 +271,32 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
visit3 = create(:visit, user: user, place: nil, name: 'Unknown Location')
create_list(:point, 5,
- user: user,
- import: import1,
- country: usa,
- visit: visit1,
- latitude: 40.7589,
- longitude: -73.9851
- )
+ user: user,
+ import: import1,
+ country: usa,
+ visit: visit1,
+ latitude: 40.7589,
+ longitude: -73.9851)
create_list(:point, 3,
- user: user,
- import: import2,
- country: canada,
- visit: visit2,
- latitude: 40.7128,
- longitude: -74.0060
- )
+ user: user,
+ import: import2,
+ country: canada,
+ visit: visit2,
+ latitude: 40.7128,
+ longitude: -74.0060)
create_list(:point, 2,
- user: user,
- import: nil,
- country: nil,
- visit: nil
- )
+ user: user,
+ import: nil,
+ country: nil,
+ visit: nil)
create_list(:point, 2,
- user: user,
- import: import1,
- country: usa,
- visit: visit3
- )
-
+ user: user,
+ import: import1,
+ country: usa,
+ visit: visit3)
end
def calculate_user_entity_counts(user)
@@ -342,11 +337,13 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
latitude: 40.7589, longitude: -73.9851
).first
- if original_office_points && target_office_points
- expect(target_office_points.import.name).to eq(original_office_points.import.name) if original_office_points.import
- expect(target_office_points.country.name).to eq(original_office_points.country.name) if original_office_points.country
- expect(target_office_points.visit.name).to eq(original_office_points.visit.name) if original_office_points.visit
+ return unless original_office_points && target_office_points
+
+ expect(target_office_points.import.name).to eq(original_office_points.import.name) if original_office_points.import
+ if original_office_points.country
+ expect(target_office_points.country.name).to eq(original_office_points.country.name)
end
+ expect(target_office_points.visit.name).to eq(original_office_points.visit.name) if original_office_points.visit
end
def verify_settings_preserved(original_user, target_user)
@@ -375,9 +372,9 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
original_export = original_user.exports.find_by(name: 'Q1 2024 Export')
target_export = target_user.exports.find_by(name: 'Q1 2024 Export')
- if original_export&.file&.attached?
- expect(target_export).to be_present
- expect(target_export.file).to be_attached
- end
+ return unless original_export&.file&.attached?
+
+ expect(target_export).to be_present
+ expect(target_export.file).to be_attached
end
end