diff --git a/.app_version b/.app_version index 27ff1af9..0f1a7dfc 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.36.4 +0.37.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 431a517e..fe29bbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,31 @@ 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.36.4] - Unreleased +# [0.37.0] - 2025-12-30 + +## Added + +- In the beginning of the year users will receive a year-end digest email with stats about their tracking activity during the past year. Users can opt out of receiving these emails in User Settings -> Notifications. Emails won't be sent if no email is configured in the SMTP settings or if user has no points tracked during the year. + +## Changed + +- Added and removed some indexes to improve the app performance based on the production usage data. + +## Changed + +- Deleting an import will now be processed in the background to prevent request timeouts for large imports. + +## Fixed + +- Deleting an import will no longer result in negative points count for the user. +- Updating stats. #2022 +- Validate trip start date to be earlier than end date. #2057 +- Fog of war radius slider in map v2 settings is now being respected correctly. #2041 +- Applying changes in map v2 settings now works correctly. #2041 +- Invalidate stats cache on recalculation and other operations that change stats data. + + +# [0.36.4] - 2025-12-26 ## Fixed @@ -14,6 +38,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Disable Family::Invitations::CleanupJob no invitations are in the database. #2043 - User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036 + # [0.36.3] - 2025-12-14 ## Added diff --git a/Gemfile b/Gemfile index 3d1e1649..8bae70ed 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,7 @@ gem 'puma' gem 'pundit', '>= 2.5.1' gem 'rails', '~> 8.0' gem 'rails_icons' +gem 'rails_pulse' gem 'redis' gem 'rexml' gem 'rgeo' diff --git a/Gemfile.lock b/Gemfile.lock index e558cc91..e1a1840c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,12 +108,12 @@ GEM aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) bcrypt (3.1.20) - benchmark (0.4.1) + benchmark (0.5.0) bigdecimal (3.3.1) bindata (2.5.1) bootsnap (1.18.6) msgpack (~> 1.2) - brakeman (7.1.0) + brakeman (7.1.1) racc builder (3.3.0) bundler-audit (0.9.2) @@ -133,14 +133,15 @@ GEM chunky_png (1.4.0) coderay (1.1.3) concurrent-ruby (1.3.5) - connection_pool (2.5.4) - crack (1.0.0) + connection_pool (2.5.5) + crack (1.0.1) bigdecimal rexml crass (1.0.6) cronex (0.15.0) tzinfo unicode (>= 0.4.4.5) + css-zero (1.1.15) csv (3.3.4) data_migrate (11.3.1) activerecord (>= 6.1) @@ -166,7 +167,7 @@ GEM drb (2.2.3) email_validator (2.2.4) activemodel - erb (5.1.3) + erb (6.0.0) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -208,7 +209,7 @@ GEM ffi (~> 1.9) rgeo-geojson (~> 2.1) zeitwerk (~> 2.5) - hashdiff (1.1.2) + hashdiff (1.2.1) hashie (5.0.0) httparty (0.23.1) csv @@ -221,7 +222,7 @@ GEM activesupport (>= 6.0.0) railties (>= 6.0.0) io-console (0.8.1) - irb (1.15.2) + irb (1.15.3) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -272,7 +273,7 @@ GEM method_source (1.1.0) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.26.0) + minitest (5.26.2) msgpack (1.7.3) multi_json (1.15.0) multi_xml (0.7.1) @@ -351,6 +352,9 @@ GEM optimist (3.2.1) orm_adapter (0.5.0) ostruct (0.6.1) + pagy (43.2.2) + json + yaml parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) @@ -379,14 +383,14 @@ GEM psych (5.2.6) date stringio - public_suffix (6.0.1) + public_suffix (6.0.2) puma (7.1.0) nio4r (~> 2.0) pundit (2.5.2) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.3) + rack (3.2.4) rack-oauth2 (2.3.0) activesupport attr_required @@ -429,6 +433,14 @@ GEM rails_icons (1.4.0) nokogiri (~> 1.16, >= 1.16.4) rails (> 6.1) + rails_pulse (0.2.4) + css-zero (~> 1.1, >= 1.1.4) + groupdate (~> 6.0) + pagy (>= 8, < 44) + rails (>= 7.1.0, < 9.0.0) + ransack (~> 4.0) + request_store (~> 1.5) + turbo-rails (~> 2.0.11) railties (8.0.3) actionpack (= 8.0.3) activesupport (= 8.0.3) @@ -440,16 +452,20 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.1) - rdoc (6.15.0) + ransack (4.4.1) + activerecord (>= 7.2) + activesupport (>= 7.2) + i18n + rdoc (6.16.1) erb psych (>= 4.0.0) tsort - redis (5.4.0) + redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.24.0) + redis-client (0.26.1) connection_pool regexp_parser (2.11.3) - reline (0.6.2) + reline (0.6.3) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) @@ -525,10 +541,10 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sentry-rails (6.0.0) + sentry-rails (6.1.1) railties (>= 5.2.0) - sentry-ruby (~> 6.0.0) - sentry-ruby (6.0.0) + sentry-ruby (~> 6.1.1) + sentry-ruby (6.1.1) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) shoulda-matchers (6.5.0) @@ -565,7 +581,7 @@ GEM stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) + stringio (3.1.8) strong_migrations (2.5.1) activerecord (>= 7.1) super_diff (0.17.0) @@ -589,7 +605,7 @@ GEM thor (1.4.0) timeout (0.4.4) tsort (0.2.0) - turbo-rails (2.0.17) + turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) @@ -598,7 +614,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.4) + uri (1.1.1) useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) @@ -610,7 +626,7 @@ GEM activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.25.1) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -625,6 +641,7 @@ GEM zeitwerk (>= 2.7) xpath (3.2.0) nokogiri (~> 1.8) + yaml (0.4.0) zeitwerk (2.7.3) PLATFORMS @@ -677,6 +694,7 @@ DEPENDENCIES pundit (>= 2.5.1) rails (~> 8.0) rails_icons + rails_pulse redis rexml rgeo diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index cdc6deaf..ce26c582 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -2,5 +2,5 @@ --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error{border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/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-primary{--chkbg:var(--fallback-p,oklch(var(--p)/1));--chkfg:var(--fallback-pc,oklch(var(--pc)/1));--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.checkbox-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.checkbox-primary:checked,.checkbox-primary[aria-checked=true],.checkbox-primary[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)))}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}details.collapse{width:100%}details.collapse summary{display:block;outline:2px solid transparent;outline-offset:2px;position:relative}details.collapse summary::-webkit-details-marker{display:none}.collapse:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse:has(.collapse-title:focus-visible),.collapse:has(>input[type=checkbox]:focus-visible),.collapse:has(>input[type=radio]:focus-visible){outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse-arrow>.collapse-title:after{--tw-translate-y:-100%;--tw-rotate:45deg;box-shadow:2px 2px;content:"";top:1.9rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform-origin:75% 75%;transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse-arrow>.collapse-title:after,.collapse-plus>.collapse-title:after{display:block;height:.5rem;inset-inline-end:1.4rem;pointer-events:none;position:absolute;transition-property:all;width:.5rem}.collapse-plus>.collapse-title:after{content:"+";top:.9rem;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse:not(.collapse-open):not(.collapse-close)>.collapse-title,.collapse:not(.collapse-open):not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-open):not(.collapse-close)>input[type=radio]:not(:checked){cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open])>.collapse-title{cursor:unset}.collapse-title{position:relative}:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){z-index:1}.collapse-title,:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){min-height:3.75rem;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;width:100%}.collapse-open>:where(.collapse-content),.collapse:focus:not(.collapse-close)>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input[type=checkbox]:checked~.collapse-content),.collapse:not(.collapse-close)>:where(input[type=radio]:checked~.collapse-content),.collapse[open]>:where(.collapse-content){padding-bottom:1rem;transition:padding .2s ease-out,background-color .2s ease-out}.collapse-arrow:focus:not(.collapse-close)>.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse-open.collapse-arrow>.collapse-title:after,.collapse[open].collapse-arrow>.collapse-title:after{--tw-translate-y:-50%;--tw-rotate:225deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.collapse-open.collapse-plus>.collapse-title:after,.collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse[open].collapse-plus>.collapse-title:after{content:"−"}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.\!input:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .\!input:before{aspect-ratio:1/1!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.\!modal::backdrop,.\!modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out!important;background-color:#0006!important}.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:target .modal-box,.\!modal[open] .modal-box,.modal-toggle:checked+.\!modal .modal-box{--tw-translate-y:0px!important;--tw-scale-x:1!important;--tw-scale-y:1!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.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)}.range-error{--range-shdw:var(--fallback-er,oklch(var(--er)/1))}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.steps .step-info+.step-info:before,.steps .step-info:after{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.steps .step-info:after{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.steps .step-success+.step-success:before,.steps .step-success:after{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.steps .step-success:after{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.steps .step-warning+.step-warning:before,.steps .step-warning:after{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.steps .step-warning:after{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.steps .step-error+.step-error:before,.steps .step-error:after{--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)))}.steps .step-error:after{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.textarea-disabled,.textarea:disabled,.textarea[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}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.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-error:focus-visible{outline-color:var(--fallback-er,oklch(var(--er)/1))}.toggle-error:checked,.toggle-error[aria-checked=true],.toggle-error[checked=true]{border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.btn-block{width:100%}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[type=checkbox].checkbox-xs{height:1rem;width:1rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}[type=radio].radio-sm{height:1.25rem;width:1.25rem}.range-sm{height:1.25rem}.range-sm::-webkit-slider-runnable-track{height:.25rem}.range-sm::-moz-range-track{height:.25rem}.range-sm::-webkit-slider-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.range-sm::-moz-range-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .toast:where(.toast-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[10000\]{z-index:10000}.z-\[1\]{z-index:1}.z-\[6000\]{z-index:6000}.z-\[9999\]{z-index:9999}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-14{margin-left:3.5rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-full{max-height:100%}.min-h-80{min-height:20rem}.min-h-\[4rem\]{min-height:4rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-base-content\/20{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-primary\/10{background-color:var(--fallback-p,oklch(var(--p)/.1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity,1)))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.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\/40{color:var(--fallback-bc,oklch(var(--bc)/.4))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-2{--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)}.ring-primary{--tw-ring-opacity:1;--tw-ring-color:var(--fallback-p,oklch(var(--p)/var(--tw-ring-opacity,1)))}.ring-offset-2{--tw-ring-offset-width:2px}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);cursor:default;height:auto;max-height:calc(100% - 20px);opacity:0;position:absolute;right:70px;top:10px;transform:scale(.95);transition:opacity .2s ease-in-out,transform .2s ease-in-out,visibility .2s;visibility:hidden;width:24rem;z-index:450}.leaflet-drawer *{cursor:default}.leaflet-drawer .btn,.leaflet-drawer a,.leaflet-drawer button,.leaflet-drawer input[type=checkbox]{cursor:pointer}.leaflet-drawer.open{opacity:1;transform:scale(1);visibility:visible}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{z-index:500}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{width:100%}em-emoji-picker{--color-border-over:rgba(0,0,0,.1);--color-border:rgba(0,0,0,.05);--font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--rgb-accent:96,165,250;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);max-width:400px;min-width:318px;overflow:auto;position:absolute;resize:horizontal;z-index:1000}[data-theme=dark] em-emoji-picker,html.dark em-emoji-picker{--color-border-over:hsla(0,0%,100%,.1);--color-border:hsla(0,0%,100%,.05);--rgb-accent:96,165,250}@media (max-width:768px){em-emoji-picker{max-width:90vw;min-width:280px}}.color-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:none;border-radius:.5rem}.color-input::-moz-color-swatch{border:none;border-radius:.5rem}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-info{background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)));color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.badge-info,.badge-success{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.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-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-error,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-error{background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));color:var(--fallback-erc,oklch(var(--erc)/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-primary{--chkbg:var(--fallback-p,oklch(var(--p)/1));--chkfg:var(--fallback-pc,oklch(var(--pc)/1));--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.checkbox-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.checkbox-primary:checked,.checkbox-primary[aria-checked=true],.checkbox-primary[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)))}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}details.collapse{width:100%}details.collapse summary{display:block;outline:2px solid transparent;outline-offset:2px;position:relative}details.collapse summary::-webkit-details-marker{display:none}.collapse:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse:has(.collapse-title:focus-visible),.collapse:has(>input[type=checkbox]:focus-visible),.collapse:has(>input[type=radio]:focus-visible){outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse-arrow>.collapse-title:after{--tw-translate-y:-100%;--tw-rotate:45deg;box-shadow:2px 2px;content:"";top:1.9rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform-origin:75% 75%;transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse-arrow>.collapse-title:after,.collapse-plus>.collapse-title:after{display:block;height:.5rem;inset-inline-end:1.4rem;pointer-events:none;position:absolute;transition-property:all;width:.5rem}.collapse-plus>.collapse-title:after{content:"+";top:.9rem;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse:not(.collapse-open):not(.collapse-close)>.collapse-title,.collapse:not(.collapse-open):not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-open):not(.collapse-close)>input[type=radio]:not(:checked){cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open])>.collapse-title{cursor:unset}.collapse-title{position:relative}:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){z-index:1}.collapse-title,:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){min-height:3.75rem;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;width:100%}.collapse-open>:where(.collapse-content),.collapse:focus:not(.collapse-close)>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input[type=checkbox]:checked~.collapse-content),.collapse:not(.collapse-close)>:where(input[type=radio]:checked~.collapse-content),.collapse[open]>:where(.collapse-content){padding-bottom:1rem;transition:padding .2s ease-out,background-color .2s ease-out}.collapse-arrow:focus:not(.collapse-close)>.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse-open.collapse-arrow>.collapse-title:after,.collapse[open].collapse-arrow>.collapse-title:after{--tw-translate-y:-50%;--tw-rotate:225deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.collapse-open.collapse-plus>.collapse-title:after,.collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse[open].collapse-plus>.collapse-title:after{content:"−"}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.\!input:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .\!input:before{aspect-ratio:1/1!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.\!modal::backdrop,.\!modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out!important;background-color:#0006!important}.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:target .modal-box,.\!modal[open] .modal-box,.modal-toggle:checked+.\!modal .modal-box{--tw-translate-y:0px!important;--tw-scale-x:1!important;--tw-scale-y:1!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.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)}.range-error{--range-shdw:var(--fallback-er,oklch(var(--er)/1))}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.steps .step-info+.step-info:before,.steps .step-info:after{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.steps .step-info:after{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.steps .step-success+.step-success:before,.steps .step-success:after{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.steps .step-success:after{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.steps .step-warning+.step-warning:before,.steps .step-warning:after{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.steps .step-warning:after{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.steps .step-error+.step-error:before,.steps .step-error:after{--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)))}.steps .step-error:after{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.textarea-disabled,.textarea:disabled,.textarea[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}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.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-error:focus-visible{outline-color:var(--fallback-er,oklch(var(--er)/1))}.toggle-error:checked,.toggle-error[aria-checked=true],.toggle-error[checked=true]{border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.btn-block{width:100%}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[type=checkbox].checkbox-xs{height:1rem;width:1rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}[type=radio].radio-sm{height:1.25rem;width:1.25rem}.range-sm{height:1.25rem}.range-sm::-webkit-slider-runnable-track{height:.25rem}.range-sm::-moz-range-track{height:.25rem}.range-sm::-webkit-slider-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.range-sm::-moz-range-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .toast:where(.toast-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[10000\]{z-index:10000}.z-\[1\]{z-index:1}.z-\[6000\]{z-index:6000}.z-\[9999\]{z-index:9999}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-14{margin-left:3.5rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-full{max-height:100%}.min-h-80{min-height:20rem}.min-h-\[4rem\]{min-height:4rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-0{min-width:0}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-screen-2xl{max-width:1536px}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-base-content\/20{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-primary\/10{background-color:var(--fallback-p,oklch(var(--p)/.1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity,1)))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.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\/40{color:var(--fallback-bc,oklch(var(--bc)/.4))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-2{--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)}.ring-primary{--tw-ring-opacity:1;--tw-ring-color:var(--fallback-p,oklch(var(--p)/var(--tw-ring-opacity,1)))}.ring-offset-2{--tw-ring-offset-width:2px}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);cursor:default;height:auto;max-height:calc(100% - 20px);opacity:0;position:absolute;right:70px;top:10px;transform:scale(.95);transition:opacity .2s ease-in-out,transform .2s ease-in-out,visibility .2s;visibility:hidden;width:24rem;z-index:450}.leaflet-drawer *{cursor:default}.leaflet-drawer .btn,.leaflet-drawer a,.leaflet-drawer button,.leaflet-drawer input[type=checkbox]{cursor:pointer}.leaflet-drawer.open{opacity:1;transform:scale(1);visibility:visible}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{z-index:500}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{width:100%}em-emoji-picker{--color-border-over:rgba(0,0,0,.1);--color-border:rgba(0,0,0,.05);--font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--rgb-accent:96,165,250;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);max-width:400px;min-width:318px;overflow:auto;position:absolute;resize:horizontal;z-index:1000}[data-theme=dark] em-emoji-picker,html.dark em-emoji-picker{--color-border-over:hsla(0,0%,100%,.1);--color-border:hsla(0,0%,100%,.05);--rgb-accent:96,165,250}@media (max-width:768px){em-emoji-picker{max-width:90vw;min-width:280px}}.color-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:none;border-radius:.5rem}.color-input::-moz-color-swatch{border:none;border-radius:.5rem}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact .timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:scale-105:hover,.hover\:scale-110: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-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) 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\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-md:hover{--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);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:scale-105{--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))}@media (min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:\!block{display:block!important}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:items-end{align-items:flex-end}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}.lg\:text-left{text-align:left}} \ No newline at end of file diff --git a/app/assets/svg/icons/lucide/outline/calendar-plus-2.svg b/app/assets/svg/icons/lucide/outline/calendar-plus-2.svg new file mode 100644 index 00000000..890ec3bb --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/calendar-plus-2.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/mail.svg b/app/assets/svg/icons/lucide/outline/mail.svg new file mode 100644 index 00000000..f4046c96 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/mail.svg @@ -0,0 +1 @@ + diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index 1595d326..8c5424be 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -13,6 +13,7 @@ class Api::V1::PointsController < ApiController points = current_api_user .points + .without_raw_data .where(timestamp: start_at..end_at) # Filter by geographic bounds if provided diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb index 0c59e1bf..3b669425 100644 --- a/app/controllers/exports_controller.rb +++ b/app/controllers/exports_controller.rb @@ -7,7 +7,7 @@ class ExportsController < ApplicationController before_action :set_export, only: %i[destroy] def index - @exports = current_user.exports.order(created_at: :desc).page(params[:page]) + @exports = current_user.exports.with_attached_file.order(created_at: :desc).page(params[:page]) end def create diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 96049978..29b84530 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -14,6 +14,7 @@ class ImportsController < ApplicationController def index @imports = policy_scope(Import) .select(:id, :name, :source, :created_at, :processed, :status) + .with_attached_file .order(created_at: :desc) .page(params[:page]) end @@ -78,9 +79,13 @@ class ImportsController < ApplicationController end def destroy - Imports::Destroy.new(current_user, @import).call + @import.deleting! + Imports::DestroyJob.perform_later(@import.id) - redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other + respond_to do |format| + format.html { redirect_to imports_url, notice: 'Import is being deleted.', status: :see_other } + format.turbo_stream + end end private diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 1a34fed4..aba7b88a 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -35,7 +35,7 @@ class SettingsController < ApplicationController :meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key, - :visits_suggestions_enabled + :visits_suggestions_enabled, :digest_emails_enabled ) end end diff --git a/app/controllers/shared/digests_controller.rb b/app/controllers/shared/digests_controller.rb new file mode 100644 index 00000000..9e390041 --- /dev/null +++ b/app/controllers/shared/digests_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Shared::DigestsController < ApplicationController + helper Users::DigestsHelper + helper CountryFlagHelper + + before_action :authenticate_user!, except: [:show] + before_action :authenticate_active_user!, only: [:update] + + def show + @digest = Users::Digest.find_by(sharing_uuid: params[:uuid]) + + unless @digest&.public_accessible? + return redirect_to root_path, + alert: 'Shared digest not found or no longer available' + end + + @year = @digest.year + @user = @digest.user + @distance_unit = @user.safe_settings.distance_unit || 'km' + @is_public_view = true + + render 'users/digests/public_year' + end + + def update + @year = params[:year].to_i + @digest = current_user.digests.yearly.find_by(year: @year) + + return head :not_found unless @digest + + if params[:enabled] == '1' + @digest.enable_sharing!(expiration: params[:expiration] || '24h') + sharing_url = shared_users_digest_url(@digest.sharing_uuid) + + render json: { + success: true, + sharing_url: sharing_url, + message: 'Sharing enabled successfully' + } + else + @digest.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/users/digests_controller.rb b/app/controllers/users/digests_controller.rb new file mode 100644 index 00000000..5016b81f --- /dev/null +++ b/app/controllers/users/digests_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class Users::DigestsController < ApplicationController + helper Users::DigestsHelper + helper CountryFlagHelper + + before_action :authenticate_user! + before_action :authenticate_active_user!, only: [:create] + before_action :set_digest, only: [:show] + + def index + @digests = current_user.digests.yearly.order(year: :desc) + @available_years = available_years_for_generation + end + + def show + @distance_unit = current_user.safe_settings.distance_unit || 'km' + end + + def create + year = params[:year].to_i + + if valid_year?(year) + Users::Digests::CalculatingJob.perform_later(current_user.id, year) + redirect_to users_digests_path, + notice: "Year-end digest for #{year} is being generated. Check back soon!", + status: :see_other + else + redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other + end + end + + private + + def set_digest + @digest = current_user.digests.yearly.find_by!(year: params[:year]) + rescue ActiveRecord::RecordNotFound + redirect_to users_digests_path, alert: 'Digest not found' + end + + def available_years_for_generation + tracked_years = current_user.stats.select(:year).distinct.pluck(:year) + existing_digests = current_user.digests.yearly.pluck(:year) + + (tracked_years - existing_digests).sort.reverse + end + + def valid_year?(year) + return false if year < 2000 || year > Time.current.year + + current_user.stats.exists?(year: year) + end +end diff --git a/app/helpers/users/digests_helper.rb b/app/helpers/users/digests_helper.rb new file mode 100644 index 00000000..13058203 --- /dev/null +++ b/app/helpers/users/digests_helper.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Users + module DigestsHelper + def distance_with_unit(distance_meters, unit) + value = Users::Digest.convert_distance(distance_meters, unit).round + "#{number_with_delimiter(value)} #{unit}" + end + + def distance_comparison_text(distance_meters) + distance_km = distance_meters.to_f / 1000 + + if distance_km >= Users::Digest::MOON_DISTANCE_KM + percentage = ((distance_km / Users::Digest::MOON_DISTANCE_KM) * 100).round(1) + "That's #{percentage}% of the distance to the Moon!" + else + percentage = ((distance_km / Users::Digest::EARTH_CIRCUMFERENCE_KM) * 100).round(1) + "That's #{percentage}% of Earth's circumference!" + end + end + + def format_time_spent(minutes) + return "#{minutes} minutes" if minutes < 60 + + hours = minutes / 60 + remaining_minutes = minutes % 60 + + if hours < 24 + "#{hours}h #{remaining_minutes}m" + else + days = hours / 24 + remaining_hours = hours % 24 + "#{days}d #{remaining_hours}h" + end + end + + def yoy_change_class(change) + return '' if change.nil? + + change.negative? ? 'negative' : 'positive' + end + + def yoy_change_text(change) + return '' if change.nil? + + prefix = change.positive? ? '+' : '' + "#{prefix}#{change}%" + end + end +end diff --git a/app/javascript/controllers/datetime_controller.js b/app/javascript/controllers/datetime_controller.js index b03df4ca..cc78ea44 100644 --- a/app/javascript/controllers/datetime_controller.js +++ b/app/javascript/controllers/datetime_controller.js @@ -11,9 +11,57 @@ export default class extends BaseController { connect() { console.log("Datetime controller connected") this.debounceTimer = null; + + // Add validation listeners + if (this.hasStartedAtTarget && this.hasEndedAtTarget) { + // Validate on change to set validation state + this.startedAtTarget.addEventListener('change', () => this.validateDates()) + this.endedAtTarget.addEventListener('change', () => this.validateDates()) + + // Validate on blur to set validation state + this.startedAtTarget.addEventListener('blur', () => this.validateDates()) + this.endedAtTarget.addEventListener('blur', () => this.validateDates()) + + // Add form submit validation + const form = this.element.closest('form') + if (form) { + form.addEventListener('submit', (e) => { + if (!this.validateDates()) { + e.preventDefault() + this.endedAtTarget.reportValidity() + } + }) + } + } } - async updateCoordinates(event) { + validateDates(showPopup = false) { + const startDate = new Date(this.startedAtTarget.value) + const endDate = new Date(this.endedAtTarget.value) + + // Clear any existing custom validity + this.startedAtTarget.setCustomValidity('') + this.endedAtTarget.setCustomValidity('') + + // Check if both dates are valid + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return true + } + + // Validate that start date is before end date + if (startDate >= endDate) { + const errorMessage = 'Start date must be earlier than end date' + this.endedAtTarget.setCustomValidity(errorMessage) + if (showPopup) { + this.endedAtTarget.reportValidity() + } + return false + } + + return true + } + + async updateCoordinates() { // Clear any existing timeout if (this.debounceTimer) { clearTimeout(this.debounceTimer); @@ -25,6 +73,11 @@ export default class extends BaseController { const endedAt = this.endedAtTarget.value const apiKey = this.apiKeyTarget.value + // Validate dates before making API call (don't show popup, already shown on change) + if (!this.validateDates(false)) { + return + } + if (startedAt && endedAt) { try { const params = new URLSearchParams({ diff --git a/app/javascript/controllers/imports_controller.js b/app/javascript/controllers/imports_controller.js index 4a864074..18205967 100644 --- a/app/javascript/controllers/imports_controller.js +++ b/app/javascript/controllers/imports_controller.js @@ -26,16 +26,23 @@ export default class extends BaseController { received: (data) => { const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`); - if (row) { - const pointsCell = row.querySelector('[data-points-count]'); - if (pointsCell) { - pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count); - } + if (!row) return; - const statusCell = row.querySelector('[data-status-display]'); - if (statusCell && data.import.status) { - statusCell.textContent = data.import.status; - } + // Handle deletion complete - remove the row + if (data.action === 'delete') { + row.remove(); + return; + } + + // Handle status and points updates + const pointsCell = row.querySelector('[data-points-count]'); + if (pointsCell && data.import.points_count !== undefined) { + pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count); + } + + const statusCell = row.querySelector('[data-status-display]'); + if (statusCell && data.import.status) { + statusCell.textContent = data.import.status; } } } diff --git a/app/javascript/controllers/maps/maplibre/layer_manager.js b/app/javascript/controllers/maps/maplibre/layer_manager.js index b31fd539..2968713e 100644 --- a/app/javascript/controllers/maps/maplibre/layer_manager.js +++ b/app/javascript/controllers/maps/maplibre/layer_manager.js @@ -270,7 +270,7 @@ export class LayerManager { // Always create fog layer for backward compatibility if (!this.layers.fogLayer) { this.layers.fogLayer = new FogLayer(this.map, { - clearRadius: 1000, + clearRadius: this.settings.fogOfWarRadius || 1000, visible: this.settings.fogEnabled || false }) this.layers.fogLayer.add(pointsGeoJSON) diff --git a/app/javascript/controllers/maps/maplibre/settings_manager.js b/app/javascript/controllers/maps/maplibre/settings_manager.js index 4a9aae05..02c7ae88 100644 --- a/app/javascript/controllers/maps/maplibre/settings_manager.js +++ b/app/javascript/controllers/maps/maplibre/settings_manager.js @@ -59,7 +59,8 @@ export class SettingsController { Object.entries(toggleMap).forEach(([targetName, settingKey]) => { const target = `${targetName}Target` - if (controller[target]) { + const hasTarget = `has${targetName.charAt(0).toUpperCase()}${targetName.slice(1)}Target` + if (controller[hasTarget]) { controller[target].checked = this.settings[settingKey] } }) @@ -75,7 +76,7 @@ export class SettingsController { } // Show/hide family members list based on initial toggle state - if (controller.hasFamilyToggleTarget && controller.hasFamilyMembersListTarget) { + if (controller.hasFamilyToggleTarget && controller.hasFamilyMembersListTarget && controller.familyToggleTarget) { controller.familyMembersListTarget.style.display = controller.familyToggleTarget.checked ? 'block' : 'none' } @@ -244,8 +245,8 @@ export class SettingsController { if (settings.fogOfWarRadius) { fogLayer.clearRadius = settings.fogOfWarRadius } - // Redraw fog layer - if (fogLayer.visible) { + // Redraw fog layer if it has data and is visible + if (fogLayer.visible && fogLayer.data) { await fogLayer.update(fogLayer.data) } } diff --git a/app/javascript/maps_maplibre/layers/fog_layer.js b/app/javascript/maps_maplibre/layers/fog_layer.js index 431226d6..1112a9b7 100644 --- a/app/javascript/maps_maplibre/layers/fog_layer.js +++ b/app/javascript/maps_maplibre/layers/fog_layer.js @@ -12,9 +12,11 @@ export class FogLayer { this.ctx = null this.clearRadius = options.clearRadius || 1000 // meters this.points = [] + this.data = null // Store original data for updates } add(data) { + this.data = data // Store for later updates this.points = data.features || [] this.createCanvas() if (this.visible) { @@ -24,6 +26,7 @@ export class FogLayer { } update(data) { + this.data = data // Store for later updates this.points = data.features || [] this.render() } @@ -78,6 +81,7 @@ export class FogLayer { // Clear circles around visited points this.ctx.globalCompositeOperation = 'destination-out' + this.ctx.fillStyle = 'rgba(0, 0, 0, 1)' // Fully opaque to completely clear fog this.points.forEach(feature => { const coords = feature.geometry.coordinates diff --git a/app/javascript/maps_maplibre/layers/heatmap_layer.js b/app/javascript/maps_maplibre/layers/heatmap_layer.js index 3802e497..e9ce73ca 100644 --- a/app/javascript/maps_maplibre/layers/heatmap_layer.js +++ b/app/javascript/maps_maplibre/layers/heatmap_layer.js @@ -3,14 +3,10 @@ import { BaseLayer } from './base_layer' /** * Heatmap layer showing point density * Uses MapLibre's native heatmap for performance - * Fixed radius: 20 pixels */ export class HeatmapLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'heatmap', ...options }) - this.radius = 20 // Fixed radius - this.weight = options.weight || 1 - this.intensity = 1 // Fixed intensity this.opacity = options.opacity || 0.6 } @@ -31,53 +27,52 @@ export class HeatmapLayer extends BaseLayer { type: 'heatmap', source: this.sourceId, paint: { - // Increase weight as diameter increases - 'heatmap-weight': [ - 'interpolate', - ['linear'], - ['get', 'weight'], - 0, 0, - 6, 1 - ], + // Fixed weight + 'heatmap-weight': 1, - // Increase intensity as zoom increases + // low intensity to view major clusters 'heatmap-intensity': [ 'interpolate', ['linear'], ['zoom'], - 0, this.intensity, - 9, this.intensity * 3 + 0, 0.01, + 10, 0.1, + 15, 0.3 ], - // Color ramp from blue to red + // Color ramp 'heatmap-color': [ 'interpolate', ['linear'], ['heatmap-density'], - 0, 'rgba(33,102,172,0)', - 0.2, 'rgb(103,169,207)', - 0.4, 'rgb(209,229,240)', - 0.6, 'rgb(253,219,199)', - 0.8, 'rgb(239,138,98)', + 0, 'rgba(0,0,0,0)', + 0.4, 'rgba(0,0,0,0)', + 0.65, 'rgba(33,102,172,0.4)', + 0.7, 'rgb(103,169,207)', + 0.8, 'rgb(209,229,240)', + 0.9, 'rgb(253,219,199)', + 0.95, 'rgb(239,138,98)', 1, 'rgb(178,24,43)' ], - // Fixed radius adjusted by zoom level + // Radius in pixels, exponential growth 'heatmap-radius': [ 'interpolate', - ['linear'], + ['exponential', 2], ['zoom'], - 0, this.radius, - 9, this.radius * 3 + 10, 5, + 15, 10, + 20, 160 ], - // Transition from heatmap to circle layer by zoom level + // Visible when zoomed in, fades when zoomed out 'heatmap-opacity': [ 'interpolate', ['linear'], ['zoom'], - 7, this.opacity, - 9, 0 + 0, 0.3, + 10, this.opacity, + 15, this.opacity ] } } diff --git a/app/jobs/bulk_visits_suggesting_job.rb b/app/jobs/bulk_visits_suggesting_job.rb index e52b06da..5ffd9232 100644 --- a/app/jobs/bulk_visits_suggesting_job.rb +++ b/app/jobs/bulk_visits_suggesting_job.rb @@ -18,7 +18,7 @@ class BulkVisitsSuggestingJob < ApplicationJob users.active.find_each do |user| next unless user.safe_settings.visits_suggestions_enabled? - next unless user.points_count.positive? + next unless user.points_count&.positive? schedule_chunked_jobs(user, time_chunks) end diff --git a/app/jobs/imports/destroy_job.rb b/app/jobs/imports/destroy_job.rb new file mode 100644 index 00000000..952f9eae --- /dev/null +++ b/app/jobs/imports/destroy_job.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Imports::DestroyJob < ApplicationJob + queue_as :default + + def perform(import_id) + import = Import.find_by(id: import_id) + return unless import + + import.deleting! + broadcast_status_update(import) + + Imports::Destroy.new(import.user, import).call + + broadcast_deletion_complete(import) + rescue ActiveRecord::RecordNotFound + Rails.logger.warn "Import #{import_id} not found, may have already been deleted" + end + + private + + def broadcast_status_update(import) + ImportsChannel.broadcast_to( + import.user, + { + action: 'status_update', + import: { + id: import.id, + status: import.status + } + } + ) + end + + def broadcast_deletion_complete(import) + ImportsChannel.broadcast_to( + import.user, + { + action: 'delete', + import: { + id: import.id + } + } + ) + end +end diff --git a/app/jobs/points/nightly_reverse_geocoding_job.rb b/app/jobs/points/nightly_reverse_geocoding_job.rb index d536679f..d0d5de51 100644 --- a/app/jobs/points/nightly_reverse_geocoding_job.rb +++ b/app/jobs/points/nightly_reverse_geocoding_job.rb @@ -6,8 +6,15 @@ class Points::NightlyReverseGeocodingJob < ApplicationJob def perform return unless DawarichSettings.reverse_geocoding_enabled? + processed_user_ids = Set.new + Point.not_reverse_geocoded.find_each(batch_size: 1000) do |point| point.async_reverse_geocode + processed_user_ids.add(point.user_id) + end + + processed_user_ids.each do |user_id| + Cache::InvalidateUserCaches.new(user_id).call end end end diff --git a/app/jobs/tracks/daily_generation_job.rb b/app/jobs/tracks/daily_generation_job.rb index 095199c1..1a6958eb 100644 --- a/app/jobs/tracks/daily_generation_job.rb +++ b/app/jobs/tracks/daily_generation_job.rb @@ -21,7 +21,7 @@ class Tracks::DailyGenerationJob < ApplicationJob def perform User.active_or_trial.find_each do |user| - next if user.points_count.zero? + next if user.points_count&.zero? process_user_daily_tracks(user) rescue StandardError => e diff --git a/app/jobs/users/digests/calculating_job.rb b/app/jobs/users/digests/calculating_job.rb new file mode 100644 index 00000000..aaa6c5fb --- /dev/null +++ b/app/jobs/users/digests/calculating_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Users::Digests::CalculatingJob < ApplicationJob + queue_as :digests + + def perform(user_id, year) + Users::Digests::CalculateYear.new(user_id, year).call + rescue StandardError => e + create_digest_failed_notification(user_id, e) + end + + private + + def create_digest_failed_notification(user_id, error) + user = User.find(user_id) + + Notifications::Create.new( + user:, + kind: :error, + title: 'Year-End Digest calculation failed', + content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}" + ).call + rescue ActiveRecord::RecordNotFound + nil + end +end diff --git a/app/jobs/users/digests/email_sending_job.rb b/app/jobs/users/digests/email_sending_job.rb new file mode 100644 index 00000000..dbd2665d --- /dev/null +++ b/app/jobs/users/digests/email_sending_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Users::Digests::EmailSendingJob < ApplicationJob + queue_as :mailers + + def perform(user_id, year) + user = User.find(user_id) + digest = user.digests.yearly.find_by(year: year) + + return unless should_send_email?(user, digest) + + Users::DigestsMailer.with(user: user, digest: digest).year_end_digest.deliver_later + + digest.update!(sent_at: Time.current) + rescue ActiveRecord::RecordNotFound + ExceptionReporter.call( + 'Users::Digests::EmailSendingJob', + "User with ID #{user_id} not found. Skipping year-end digest email." + ) + end + + private + + def should_send_email?(user, digest) + return false unless user.safe_settings.digest_emails_enabled? + return false if digest.blank? + return false if digest.sent_at.present? + + true + end +end diff --git a/app/jobs/users/digests/year_end_scheduling_job.rb b/app/jobs/users/digests/year_end_scheduling_job.rb new file mode 100644 index 00000000..7d673629 --- /dev/null +++ b/app/jobs/users/digests/year_end_scheduling_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Users::Digests::YearEndSchedulingJob < ApplicationJob + queue_as :digests + + def perform + year = Time.current.year - 1 # Previous year's digest + + ::User.active_or_trial.find_each do |user| + # Skip if user has no data for the year + next unless user.stats.where(year: year).exists? + + # Schedule calculation first + Users::Digests::CalculatingJob.perform_later(user.id, year) + + # Schedule email with delay to allow calculation to complete + Users::Digests::EmailSendingJob.set(wait: 30.minutes).perform_later(user.id, year) + end + end +end diff --git a/app/mailers/users/digests_mailer.rb b/app/mailers/users/digests_mailer.rb new file mode 100644 index 00000000..48467a9e --- /dev/null +++ b/app/mailers/users/digests_mailer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Users::DigestsMailer < ApplicationMailer + helper Users::DigestsHelper + helper CountryFlagHelper + + def year_end_digest + @user = params[:user] + @digest = params[:digest] + @distance_unit = @user.safe_settings.distance_unit || 'km' + + mail( + to: @user.email, + subject: "Your #{@digest.year} Year in Review - Dawarich" + ) + end +end diff --git a/app/models/import.rb b/app/models/import.rb index e69e8328..204357e3 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -17,7 +17,7 @@ class Import < ApplicationRecord validate :file_size_within_limit, if: -> { user.trial? } validate :import_count_within_limit, if: -> { user.trial? } - enum :status, { created: 0, processing: 1, completed: 2, failed: 3 } + enum :status, { created: 0, processing: 1, completed: 2, failed: 3, deleting: 4 } enum :source, { google_semantic_history: 0, owntracks: 1, google_records: 2, diff --git a/app/models/stat.rb b/app/models/stat.rb index 1bcb2cbf..4b0e6394 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -68,12 +68,14 @@ class Stat < ApplicationRecord def enable_sharing!(expiration: '1h') # Default to 24h if an invalid expiration is provided - expiration = '24h' unless %w[1h 12h 24h].include?(expiration) + expiration = '24h' unless %w[1h 12h 24h 1w 1m].include?(expiration) 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 + when '1w' then 1.week.from_now + when '1m' then 1.month.from_now end update!( diff --git a/app/models/trip.rb b/app/models/trip.rb index fca5e1e2..3b882f4b 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -9,6 +9,7 @@ class Trip < ApplicationRecord belongs_to :user validates :name, :started_at, :ended_at, presence: true + validate :started_at_before_ended_at after_create :enqueue_calculation_jobs after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? } @@ -47,4 +48,11 @@ class Trip < ApplicationRecord # to show all photos in the same height vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos end + + def started_at_before_ended_at + return if started_at.blank? || ended_at.blank? + return unless started_at >= ended_at + + errors.add(:ended_at, 'must be after start date') + end end diff --git a/app/models/user.rb b/app/models/user.rb index 6a591451..8743c132 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,6 +21,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :trips, dependent: :destroy has_many :tracks, dependent: :destroy has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy + has_many :digests, class_name: 'Users::Digest', dependent: :destroy after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } @@ -73,7 +74,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength end def total_reverse_geocoded_points - points.where.not(reverse_geocoded_at: nil).count + StatsQuery.new(self).points_stats[:geocoded] end def total_reverse_geocoded_points_without_data diff --git a/app/models/users/digest.rb b/app/models/users/digest.rb new file mode 100644 index 00000000..843aa115 --- /dev/null +++ b/app/models/users/digest.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +class Users::Digest < ApplicationRecord + self.table_name = 'digests' + + include DistanceConvertible + + EARTH_CIRCUMFERENCE_KM = 40_075 + MOON_DISTANCE_KM = 384_400 + + belongs_to :user + + validates :year, :period_type, presence: true + validates :year, uniqueness: { scope: %i[user_id period_type] } + + before_create :generate_sharing_uuid + + enum :period_type, { monthly: 0, yearly: 1 } + + def sharing_enabled? + sharing_settings.try(:[], 'enabled') == true + end + + def sharing_expired? + expiration = sharing_settings.try(:[], 'expiration') + return false if expiration.blank? + + expires_at_value = sharing_settings.try(:[], '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: '24h') + expiration = '24h' unless %w[1h 12h 24h 1w 1m].include?(expiration) + + 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 + when '1w' then 1.week.from_now + when '1m' then 1.month.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 countries_count + return 0 unless toponyms.is_a?(Array) + + toponyms.count { |t| t['country'].present? } + end + + def cities_count + return 0 unless toponyms.is_a?(Array) + + toponyms.sum { |t| t['cities']&.count || 0 } + end + + def first_time_countries + first_time_visits['countries'] || [] + end + + def first_time_cities + first_time_visits['cities'] || [] + end + + def top_countries_by_time + time_spent_by_location['countries'] || [] + end + + def top_cities_by_time + time_spent_by_location['cities'] || [] + end + + def yoy_distance_change + year_over_year['distance_change_percent'] + end + + def yoy_countries_change + year_over_year['countries_change'] + end + + def yoy_cities_change + year_over_year['cities_change'] + end + + def previous_year + year_over_year['previous_year'] + end + + def total_countries_all_time + all_time_stats['total_countries'] || 0 + end + + def total_cities_all_time + all_time_stats['total_cities'] || 0 + end + + def total_distance_all_time + (all_time_stats['total_distance'] || 0).to_i + end + + def distance_km + distance.to_f / 1000 + end + + def distance_comparison_text + if distance_km >= MOON_DISTANCE_KM + percentage = ((distance_km / MOON_DISTANCE_KM) * 100).round(1) + "That's #{percentage}% of the distance to the Moon!" + else + percentage = ((distance_km / EARTH_CIRCUMFERENCE_KM) * 100).round(1) + "That's #{percentage}% of Earth's circumference!" + end + end + + private + + def generate_sharing_uuid + self.sharing_uuid ||= SecureRandom.uuid + end +end diff --git a/app/queries/stats_query.rb b/app/queries/stats_query.rb index a2fe5c10..60dbf3a5 100644 --- a/app/queries/stats_query.rb +++ b/app/queries/stats_query.rb @@ -11,7 +11,7 @@ class StatsQuery end { - total: user.points_count, + total: user.points_count.to_i, geocoded: cached_stats[:geocoded], without_data: cached_stats[:without_data] } diff --git a/app/serializers/stats_serializer.rb b/app/serializers/stats_serializer.rb index bd3939fb..bade2fe0 100644 --- a/app/serializers/stats_serializer.rb +++ b/app/serializers/stats_serializer.rb @@ -27,7 +27,7 @@ class StatsSerializer end def reverse_geocoded_points - user.points.reverse_geocoded.count + StatsQuery.new(user).points_stats[:geocoded] end def yearly_stats diff --git a/app/services/cache/clean.rb b/app/services/cache/clean.rb index ecbfafed..e555e6a4 100644 --- a/app/services/cache/clean.rb +++ b/app/services/cache/clean.rb @@ -36,8 +36,8 @@ class Cache::Clean 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") + Rails.cache.delete("dawarich/user_#{user.id}_countries_visited") + Rails.cache.delete("dawarich/user_#{user.id}_cities_visited") end end end diff --git a/app/services/cache/invalidate_user_caches.rb b/app/services/cache/invalidate_user_caches.rb new file mode 100644 index 00000000..839efdae --- /dev/null +++ b/app/services/cache/invalidate_user_caches.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Cache::InvalidateUserCaches + # Invalidates user-specific caches that depend on point data. + # This should be called after: + # - Reverse geocoding operations (updates country/city data) + # - Stats calculations (updates geocoding stats) + # - Bulk point imports/updates + def initialize(user_id) + @user_id = user_id + end + + def call + invalidate_countries_visited + invalidate_cities_visited + invalidate_points_geocoded_stats + end + + def invalidate_countries_visited + Rails.cache.delete("dawarich/user_#{user_id}_countries_visited") + end + + def invalidate_cities_visited + Rails.cache.delete("dawarich/user_#{user_id}_cities_visited") + end + + def invalidate_points_geocoded_stats + Rails.cache.delete("dawarich/user_#{user_id}_points_geocoded_stats") + end + + private + + attr_reader :user_id +end diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index 333cb7ac..3d3ff2f4 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -10,8 +10,8 @@ class CountriesAndCities def call points - .reject { |point| point.country_name.nil? || point.city.nil? } - .group_by(&:country_name) + .reject { |point| point[:country_name].nil? || point[:city].nil? } + .group_by { |point| point[:country_name] } .transform_values { |country_points| process_country_points(country_points) } .map { |country, cities| CountryData.new(country: country, cities: cities) } end @@ -22,7 +22,7 @@ class CountriesAndCities def process_country_points(country_points) country_points - .group_by(&:city) + .group_by { |point| point[:city] } .transform_values { |city_points| create_city_data_if_valid(city_points) } .values .compact @@ -31,7 +31,7 @@ class CountriesAndCities def create_city_data_if_valid(city_points) timestamps = city_points.pluck(:timestamp) duration = calculate_duration_in_minutes(timestamps) - city = city_points.first.city + city = city_points.first[:city] points_count = city_points.size build_city_data(city, points_count, timestamps, duration) diff --git a/app/services/imports/destroy.rb b/app/services/imports/destroy.rb index 76870fdb..88f824ea 100644 --- a/app/services/imports/destroy.rb +++ b/app/services/imports/destroy.rb @@ -9,11 +9,15 @@ class Imports::Destroy end def call + points_count = @import.points_count.to_i + ActiveRecord::Base.transaction do - @import.points.delete_all + @import.points.destroy_all @import.destroy! end + Rails.logger.info "Import #{@import.id} deleted with #{points_count} points" + Stats::BulkCalculator.new(@user.id).call end end diff --git a/app/services/points/create.rb b/app/services/points/create.rb index c373fc20..a2fe1e7b 100644 --- a/app/services/points/create.rb +++ b/app/services/points/create.rb @@ -11,8 +11,7 @@ class Points::Create def call data = Points::Params.new(params, user.id).call - # Deduplicate points based on unique constraint - deduplicated_data = data.uniq { |point| [point[:lonlat], point[:timestamp], point[:user_id]] } + deduplicated_data = data.uniq { |point| [point[:lonlat], point[:timestamp].to_i, point[:user_id]] } created_points = [] diff --git a/app/services/points_limit_exceeded.rb b/app/services/points_limit_exceeded.rb index 21cb802a..c014e45d 100644 --- a/app/services/points_limit_exceeded.rb +++ b/app/services/points_limit_exceeded.rb @@ -9,7 +9,7 @@ class PointsLimitExceeded return false if DawarichSettings.self_hosted? Rails.cache.fetch(cache_key, expires_in: 1.day) do - @user.points_count >= points_limit + @user.points_count.to_i >= points_limit end end diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index c297599e..81fd0bd9 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -48,7 +48,6 @@ class ReverseGeocoding::Places::FetchData ) end - def find_place(place_data, existing_places) osm_id = place_data['properties']['osm_id'].to_s @@ -82,9 +81,9 @@ class ReverseGeocoding::Places::FetchData def find_existing_places(osm_ids) Place.where("geodata->'properties'->>'osm_id' IN (?)", osm_ids) - .global - .index_by { |p| p.geodata.dig('properties', 'osm_id').to_s } - .compact + .global + .index_by { |p| p.geodata.dig('properties', 'osm_id').to_s } + .compact end def prepare_places_for_bulk_operations(places, existing_places) @@ -114,9 +113,9 @@ class ReverseGeocoding::Places::FetchData place.geodata = data place.source = :photon - if place.lonlat.blank? - place.lonlat = build_point_coordinates(data['geometry']['coordinates']) - end + return if place.lonlat.present? + + place.lonlat = build_point_coordinates(data['geometry']['coordinates']) end def save_places(places_to_create, places_to_update) @@ -138,8 +137,23 @@ class ReverseGeocoding::Places::FetchData Place.insert_all(place_attributes) end - # Individual updates for existing places - places_to_update.each(&:save!) if places_to_update.any? + return unless places_to_update.any? + + update_attributes = places_to_update.map do |place| + { + id: place.id, + name: place.name, + latitude: place.latitude, + longitude: place.longitude, + lonlat: place.lonlat, + city: place.city, + country: place.country, + geodata: place.geodata, + source: place.source, + updated_at: Time.current + } + end + Place.upsert_all(update_attributes, unique_by: :id) end def build_point_coordinates(coordinates) @@ -147,7 +161,7 @@ class ReverseGeocoding::Places::FetchData end def geocoder_places - data = Geocoder.search( + Geocoder.search( [place.lat, place.lon], limit: 10, distance_sort: true, diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb index ff02dbbe..0a87b803 100644 --- a/app/services/stats/calculate_month.rb +++ b/app/services/stats/calculate_month.rb @@ -26,7 +26,7 @@ class Stats::CalculateMonth def start_timestamp = DateTime.new(year, month, 1).to_i def end_timestamp - DateTime.new(year, month, -1).to_i # -1 returns last day of month + DateTime.new(year, month, -1).to_i end def update_month_stats(year, month) @@ -42,6 +42,8 @@ class Stats::CalculateMonth ) stat.save! + + Cache::InvalidateUserCaches.new(user.id).call end end diff --git a/app/services/stats/hexagon_calculator.rb b/app/services/stats/hexagon_calculator.rb index 6005f9a5..2c566ec8 100644 --- a/app/services/stats/hexagon_calculator.rb +++ b/app/services/stats/hexagon_calculator.rb @@ -53,8 +53,8 @@ class Stats::HexagonCalculator # Try with lower resolution (larger hexagons) lower_resolution = [h3_resolution - 2, 0].max Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}" - # Create a new instance with lower resolution for recursion - return self.class.new(user.id, year, month).calculate_hexagons(lower_resolution) + # Recursively call with lower resolution + return calculate_hexagons(lower_resolution) end Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}" diff --git a/app/services/users/digests/calculate_year.rb b/app/services/users/digests/calculate_year.rb new file mode 100644 index 00000000..faea7d50 --- /dev/null +++ b/app/services/users/digests/calculate_year.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +module Users + module Digests + class CalculateYear + def initialize(user_id, year) + @user = ::User.find(user_id) + @year = year.to_i + end + + def call + return nil if monthly_stats.empty? + + digest = Users::Digest.find_or_initialize_by(user: user, year: year, period_type: :yearly) + + digest.assign_attributes( + distance: total_distance, + toponyms: aggregate_toponyms, + monthly_distances: build_monthly_distances, + time_spent_by_location: calculate_time_spent, + first_time_visits: calculate_first_time_visits, + year_over_year: calculate_yoy_comparison, + all_time_stats: calculate_all_time_stats + ) + + digest.save! + digest + end + + private + + attr_reader :user, :year + + def monthly_stats + @monthly_stats ||= user.stats.where(year: year).order(:month) + end + + def total_distance + monthly_stats.sum(:distance) + end + + def aggregate_toponyms + country_cities = Hash.new { |h, k| h[k] = Set.new } + + monthly_stats.each do |stat| + toponyms = stat.toponyms + next unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + next unless toponym.is_a?(Hash) + + country = toponym['country'] + next unless country.present? + + if toponym['cities'].is_a?(Array) + toponym['cities'].each do |city| + city_name = city['city'] if city.is_a?(Hash) + country_cities[country].add(city_name) if city_name.present? + end + else + # Ensure country appears even if no cities + country_cities[country] + end + end + end + + country_cities.sort_by { |country, _| country }.map do |country, cities| + { + 'country' => country, + 'cities' => cities.to_a.sort.map { |city| { 'city' => city } } + } + end + end + + def build_monthly_distances + result = {} + + monthly_stats.each do |stat| + result[stat.month.to_s] = stat.distance.to_s + end + + # Fill in missing months with 0 + (1..12).each do |month| + result[month.to_s] ||= '0' + end + + result + end + + def calculate_time_spent + country_time = Hash.new(0) + city_time = Hash.new(0) + + monthly_stats.each do |stat| + toponyms = stat.toponyms + next unless toponyms.is_a?(Array) + + toponyms.each do |toponym| + next unless toponym.is_a?(Hash) + + country = toponym['country'] + next unless toponym['cities'].is_a?(Array) + + toponym['cities'].each do |city| + next unless city.is_a?(Hash) + + stayed_for = city['stayed_for'].to_i + city_name = city['city'] + + country_time[country] += stayed_for if country.present? + city_time[city_name] += stayed_for if city_name.present? + end + end + end + + { + 'countries' => country_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } }, + 'cities' => city_time.sort_by { |_, v| -v }.first(10).map { |name, minutes| { 'name' => name, 'minutes' => minutes } } + } + end + + def calculate_first_time_visits + FirstTimeVisitsCalculator.new(user, year).call + end + + def calculate_yoy_comparison + YearOverYearCalculator.new(user, year).call + end + + def calculate_all_time_stats + { + 'total_countries' => user.countries_visited.count, + 'total_cities' => user.cities_visited.count, + 'total_distance' => user.stats.sum(:distance).to_s + } + end + end + end +end diff --git a/app/services/users/digests/first_time_visits_calculator.rb b/app/services/users/digests/first_time_visits_calculator.rb new file mode 100644 index 00000000..a3fca060 --- /dev/null +++ b/app/services/users/digests/first_time_visits_calculator.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Users + module Digests + class FirstTimeVisitsCalculator + def initialize(user, year) + @user = user + @year = year.to_i + end + + def call + { + 'countries' => first_time_countries, + 'cities' => first_time_cities + } + end + + private + + attr_reader :user, :year + + def previous_years_stats + @previous_years_stats ||= user.stats.where('year < ?', year) + end + + def current_year_stats + @current_year_stats ||= user.stats.where(year: year) + end + + def previous_countries + @previous_countries ||= extract_countries(previous_years_stats) + end + + def previous_cities + @previous_cities ||= extract_cities(previous_years_stats) + end + + def current_countries + @current_countries ||= extract_countries(current_year_stats) + end + + def current_cities + @current_cities ||= extract_cities(current_year_stats) + end + + def first_time_countries + (current_countries - previous_countries).sort + end + + def first_time_cities + (current_cities - previous_cities).sort + end + + def extract_countries(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) && t['country'].present? } + end.uniq + end + + def extract_cities(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.flat_map do |t| + next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array) + + t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) && c['city'].present? } + end + end.uniq + end + end + end +end diff --git a/app/services/users/digests/year_over_year_calculator.rb b/app/services/users/digests/year_over_year_calculator.rb new file mode 100644 index 00000000..dd3abf85 --- /dev/null +++ b/app/services/users/digests/year_over_year_calculator.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Users + module Digests + class YearOverYearCalculator + def initialize(user, year) + @user = user + @year = year.to_i + end + + def call + return {} unless previous_year_stats.exists? + + { + 'previous_year' => year - 1, + 'distance_change_percent' => calculate_distance_change_percent, + 'countries_change' => calculate_countries_change, + 'cities_change' => calculate_cities_change + }.compact + end + + private + + attr_reader :user, :year + + def previous_year_stats + @previous_year_stats ||= user.stats.where(year: year - 1) + end + + def current_year_stats + @current_year_stats ||= user.stats.where(year: year) + end + + def calculate_distance_change_percent + prev_distance = previous_year_stats.sum(:distance) + return nil if prev_distance.zero? + + curr_distance = current_year_stats.sum(:distance) + ((curr_distance - prev_distance).to_f / prev_distance * 100).round + end + + def calculate_countries_change + prev_count = count_countries(previous_year_stats) + curr_count = count_countries(current_year_stats) + + curr_count - prev_count + end + + def calculate_cities_change + prev_count = count_cities(previous_year_stats) + curr_count = count_cities(current_year_stats) + + curr_count - prev_count + end + + def count_countries(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) && t['country'].present? } + end.uniq.count + end + + def count_cities(stats) + stats.flat_map do |stat| + toponyms = stat.toponyms + next [] unless toponyms.is_a?(Array) + + toponyms.flat_map do |t| + next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array) + + t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) && c['city'].present? } + end + end.uniq.count + end + end + end +end diff --git a/app/services/users/export_data.rb b/app/services/users/export_data.rb index 80e6c486..52f9c308 100644 --- a/app/services/users/export_data.rb +++ b/app/services/users/export_data.rb @@ -323,7 +323,7 @@ class Users::ExportData trips: user.trips.count, stats: user.stats.count, notifications: user.notifications.count, - points: user.points_count, + points: user.points_count.to_i, visits: user.visits.count, places: user.visited_places.count } diff --git a/app/services/users/import_data/points.rb b/app/services/users/import_data/points.rb index 2d27b8ee..0ed04818 100644 --- a/app/services/users/import_data/points.rb +++ b/app/services/users/import_data/points.rb @@ -219,9 +219,7 @@ class Users::ImportData::Points country_key = [country_info['name'], country_info['iso_a2'], country_info['iso_a3']] country = countries_lookup[country_key] - if country.nil? && country_info['name'].present? - country = countries_lookup[country_info['name']] - end + country = countries_lookup[country_info['name']] if country.nil? && country_info['name'].present? if country attributes['country_id'] = country.id @@ -254,12 +252,12 @@ class Users::ImportData::Points end def ensure_lonlat_field(attributes, point_data) - if attributes['lonlat'].blank? && point_data['longitude'].present? && point_data['latitude'].present? - longitude = point_data['longitude'].to_f - latitude = point_data['latitude'].to_f - attributes['lonlat'] = "POINT(#{longitude} #{latitude})" - logger.debug "Reconstructed lonlat: #{attributes['lonlat']}" - end + return unless attributes['lonlat'].blank? && point_data['longitude'].present? && point_data['latitude'].present? + + longitude = point_data['longitude'].to_f + latitude = point_data['latitude'].to_f + attributes['lonlat'] = "POINT(#{longitude} #{latitude})" + logger.debug "Reconstructed lonlat: #{attributes['lonlat']}" end def normalize_timestamp_for_lookup(timestamp) diff --git a/app/services/users/safe_settings.rb b/app/services/users/safe_settings.rb index 3e01f73c..a2e91f7b 100644 --- a/app/services/users/safe_settings.rb +++ b/app/services/users/safe_settings.rb @@ -20,8 +20,9 @@ class Users::SafeSettings 'photoprism_api_key' => nil, 'maps' => { 'distance_unit' => 'km' }, 'visits_suggestions_enabled' => 'true', - 'enabled_map_layers' => ['Routes', 'Heatmap'], - 'maps_maplibre_style' => 'light' + 'enabled_map_layers' => %w[Routes Heatmap], + 'maps_maplibre_style' => 'light', + 'digest_emails_enabled' => true }.freeze def initialize(settings = {}) @@ -139,4 +140,11 @@ class Users::SafeSettings def maps_maplibre_style settings['maps_maplibre_style'] end + + def digest_emails_enabled? + value = settings['digest_emails_enabled'] + return true if value.nil? + + ActiveModel::Type::Boolean.new.cast(value) + end end diff --git a/app/views/devise/registrations/_points_usage.html.erb b/app/views/devise/registrations/_points_usage.html.erb index 68880d0d..f710865d 100644 --- a/app/views/devise/registrations/_points_usage.html.erb +++ b/app/views/devise/registrations/_points_usage.html.erb @@ -1,6 +1,6 @@

- You have used <%= number_with_delimiter(current_user.points_count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available. + You have used <%= number_with_delimiter(current_user.points_count.to_i) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.

- +

diff --git a/app/views/imports/destroy.turbo_stream.erb b/app/views/imports/destroy.turbo_stream.erb new file mode 100644 index 00000000..844a1d9c --- /dev/null +++ b/app/views/imports/destroy.turbo_stream.erb @@ -0,0 +1,24 @@ +<%= turbo_stream.replace "import-#{@import.id}" do %> + + + <%= @import.name %> (<%= @import.source %>) +   + <%= link_to '🗺️', map_path(import_id: @import.id) %> +   + <%= link_to '📋', points_path(import_id: @import.id) %> + + <%= number_to_human_size(@import.file&.byte_size) || 'N/A' %> + + <%= number_with_delimiter @import.processed %> + + deleting + <%= human_datetime(@import.created_at) %> + + + Deleting... + + +<% end %> diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb index 8cf60feb..6bffe415 100644 --- a/app/views/imports/index.html.erb +++ b/app/views/imports/index.html.erb @@ -72,10 +72,15 @@ <%= import.status %> <%= human_datetime(import.created_at) %> - <% if import.file.present? %> - <%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %> + <% if import.deleting? %> + + Deleting... + <% else %> + <% if import.file.present? %> + <%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %> + <% end %> + <%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %> <% end %> - <%= link_to 'Delete', import, data: { turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %> <% end %> diff --git a/app/views/map/maplibre/_settings_panel.html.erb b/app/views/map/maplibre/_settings_panel.html.erb index e5069c75..356c90a6 100644 --- a/app/views/map/maplibre/_settings_panel.html.erb +++ b/app/views/map/maplibre/_settings_panel.html.erb @@ -72,7 +72,7 @@ data-maps--maplibre-target="searchInput" autocomplete="off" /> - diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb index 94e70556..a90171d6 100644 --- a/app/views/settings/index.html.erb +++ b/app/views/settings/index.html.erb @@ -69,6 +69,27 @@ +
+

+ <%= icon 'mail', class: "text-primary mr-2" %> Email Preferences +

+
+
+ +
+
+
+ <% unless DawarichSettings.self_hosted? || current_user.provider.blank? %>

diff --git a/app/views/settings/users/index.html.erb b/app/views/settings/users/index.html.erb index c4c6aea0..424bc83c 100644 --- a/app/views/settings/users/index.html.erb +++ b/app/views/settings/users/index.html.erb @@ -24,7 +24,7 @@

- <%= number_with_delimiter user.points_count %> + <%= number_with_delimiter user.points_count.to_i %> <%= human_datetime(user.created_at) %> diff --git a/app/views/shared/_sharing_modal.html.erb b/app/views/shared/_sharing_modal.html.erb index 926719cb..f994558b 100644 --- a/app/views/shared/_sharing_modal.html.erb +++ b/app/views/shared/_sharing_modal.html.erb @@ -43,7 +43,9 @@ <%= options_for_select([ ['1 hour', '1h'], ['12 hours', '12h'], - ['24 hours', '24h'] + ['24 hours', '24h'], + ['1 week', '1w'], + ['1 month', '1m'] ], @stat&.sharing_settings&.dig('expiration') || '1h') %> diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index 2e9b40eb..1c478c2b 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -1,6 +1,15 @@ <% content_for :title, 'Statistics' %>
+
+

Statistics

+ <% if Date.today >= Date.new(2025, 12, 31) %> + <%= link_to users_digests_path, class: 'btn btn-outline btn-sm' do %> + <%= icon 'earth' %> Year-End Digests + <% end %> + <% end %> +
+
diff --git a/app/views/users/digests/index.html.erb b/app/views/users/digests/index.html.erb new file mode 100644 index 00000000..7b695d23 --- /dev/null +++ b/app/views/users/digests/index.html.erb @@ -0,0 +1,92 @@ +<% content_for :title, 'Year-End Digests' %> + +
+
+

+ <%= icon 'earth' %> Year-End Digests +

+ + <% if @available_years.any? && current_user.active? %> + + <% end %> +
+ + <% if @digests.empty? %> +
+
+

+ <%= icon 'earth' %>No Year-End Digests Yet +

+

+ Year-end digests are automatically generated on January 1st each year. + <% if @available_years.any? && current_user.active? %> +
Or you can manually generate one for a previous year. + <% end %> +

+
+
+ <% else %> +
+ <% @digests.each do |digest| %> +
+
+

+ <%= link_to digest.year, users_digest_path(year: digest.year), class: 'hover:text-primary' %> + <% if digest.sharing_enabled? %> + Shared + <% end %> +

+ +
+
+
Distance
+
+ <%= distance_with_unit(digest.distance, current_user.safe_settings.distance_unit) %> +
+
+ +
+
<%= digest.countries_count %>
+
Countries
+ <% if digest.first_time_countries.any? %> +
+ <%= icon 'star' %> <%= digest.first_time_countries.count %> new +
+ <% end %> +
+ +
+
<%= digest.cities_count %>
+
Cities
+ <% if digest.first_time_cities.any? %> +
+ <%= icon 'star' %> <%= digest.first_time_cities.count %> new +
+ <% end %> +
+
+ +
+ <%= link_to users_digest_path(year: digest.year), class: 'btn btn-primary btn-sm' do %> + View Details + <% end %> +
+
+
+ <% end %> +
+ <% end %> +
diff --git a/app/views/users/digests/public_year.html.erb b/app/views/users/digests/public_year.html.erb new file mode 100644 index 00000000..ec07863b --- /dev/null +++ b/app/views/users/digests/public_year.html.erb @@ -0,0 +1,189 @@ +
+ +
+
+
+

<%= @digest.year %> Year in Review

+

Your journey, by the numbers

+
+
+
+ + +
+
+
Distance traveled
+
<%= distance_with_unit(@digest.distance, @distance_unit) %>
+
<%= distance_comparison_text(@digest.distance) %>
+
+ +
+
Countries visited
+
<%= @digest.countries_count %>
+
+ <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %> +
+
+ +
+
Cities explored
+
<%= @digest.cities_count %>
+
+ <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %> +
+
+
+ + + <% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %> +
+
+

+ <%= icon 'star' %> First Time Visits +

+ + <% if @digest.first_time_countries.any? %> +
+

New Countries

+
+ <% @digest.first_time_countries.each do |country| %> + <%= country %> + <% end %> +
+
+ <% end %> + + <% if @digest.first_time_cities.any? %> +
+

New Cities

+
+ <% @digest.first_time_cities.take(5).each do |city| %> + <%= city %> + <% end %> + <% if @digest.first_time_cities.count > 5 %> + +<%= @digest.first_time_cities.count - 5 %> more + <% end %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @digest.monthly_distances.present? %> +
+
+

+ <%= icon 'activity' %> Year by Month +

+
+ <%= column_chart( + @digest.monthly_distances.sort.map { |month, distance_meters| + [Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @distance_unit).round] + }, + height: '200px', + suffix: " #{@distance_unit}", + xtitle: 'Month', + ytitle: 'Distance', + colors: [ + '#397bb5', '#5A4E9D', '#3B945E', + '#7BC96F', '#FFD54F', '#FFA94D', + '#FF6B6B', '#FF8C42', '#C97E4F', + '#8B4513', '#5A2E2E', '#265d7d' + ] + ) %> +
+
+
+ <% end %> + + + <% if @digest.top_countries_by_time.any? %> +
+
+

+ <%= icon 'map-pin' %> Where They Spent the Most Time +

+
    + <% @digest.top_countries_by_time.take(3).each do |country| %> +
  • + + <%= country_flag(country['name']) %> + <%= country['name'] %> + + <%= format_time_spent(country['minutes']) %> +
  • + <% end %> +
+
+
+ <% end %> + + +
+
+

+ <%= icon 'earth' %> Countries & Cities +

+
+ <% @digest.toponyms&.each_with_index do |country, index| %> +
+
+ + <%= country_flag(country['country']) %> + <%= country['country'] %> + + <%= country['cities']&.length || 0 %> cities +
+ +
+ <% end %> +
+ +
+ +
+ Cities visited: + <% @digest.toponyms&.each do |country| %> + <% country['cities']&.take(5)&.each do |city| %> +
<%= city['city'] %>
+ <% end %> + <% if country['cities']&.length.to_i > 5 %> +
+<%= country['cities'].length - 5 %> more
+ <% end %> + <% end %> +
+
+
+ + +
+
+

+ <%= icon 'trophy' %> All-Time Stats +

+
+
+
Countries visited
+
<%= @digest.total_countries_all_time %>
+
+
+
Cities explored
+
<%= @digest.total_cities_all_time %>
+
+
+
+
Total distance
+
<%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %>
+
+
+
+ + +
+
+ Powered by Dawarich, your personal memories mapper. +
+
+
diff --git a/app/views/users/digests/show.html.erb b/app/views/users/digests/show.html.erb new file mode 100644 index 00000000..3b69657b --- /dev/null +++ b/app/views/users/digests/show.html.erb @@ -0,0 +1,317 @@ +<% content_for :title, "#{@digest.year} Year in Review" %> + +
+ +
+
+
+

<%= @digest.year %> Year in Review

+

Your journey, by the numbers

+ +
+
+
+ + +
+
+
+ <%= icon 'map' %> Distance Traveled +
+
+ <%= distance_with_unit(@digest.distance, @distance_unit) %> +
+

<%= distance_comparison_text(@digest.distance) %>

+ <% if @digest.yoy_distance_change %> +

+ <%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %> +

+ <% end %> +
+
+ + +
+
+
+ <%= icon 'globe' %> Countries +
+
<%= @digest.countries_count %>
+
+ <%= icon 'star' %> <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %> +
+
+ +
+
+ <%= icon 'building' %> Cities +
+
<%= @digest.cities_count %>
+
+ <%= icon 'star' %> <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %> +
+
+
+ + + <% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %> +
+
+

+ <%= icon 'star' %> First Time Visits +

+ + <% if @digest.first_time_countries.any? %> +
+

New Countries

+
+ <% @digest.first_time_countries.each do |country| %> + <%= country %> + <% end %> +
+
+ <% end %> + + <% if @digest.first_time_cities.any? %> +
+

New Cities

+
+ <% @digest.first_time_cities.take(10).each do |city| %> + <%= city %> + <% end %> + <% if @digest.first_time_cities.count > 10 %> + +<%= @digest.first_time_cities.count - 10 %> more + <% end %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @digest.monthly_distances.present? %> +
+
+

+ <%= icon 'activity' %> Your Year, Month by Month +

+
+ <%= column_chart( + @digest.monthly_distances.sort.map { |month, distance_meters| + [Date::ABBR_MONTHNAMES[month.to_i], Users::Digest.convert_distance(distance_meters.to_i, @distance_unit).round] + }, + height: '250px', + suffix: " #{@distance_unit}", + xtitle: 'Month', + ytitle: 'Distance', + colors: [ + '#397bb5', '#5A4E9D', '#3B945E', + '#7BC96F', '#FFD54F', '#FFA94D', + '#FF6B6B', '#FF8C42', '#C97E4F', + '#8B4513', '#5A2E2E', '#265d7d' + ] + ) %> +
+
+
+ <% end %> + + + <% if @digest.top_countries_by_time.any? %> +
+
+

+ <%= icon 'map-pin' %> Where You Spent the Most Time +

+
+ <% @digest.top_countries_by_time.take(5).each_with_index do |country, index| %> +
+
+ + <%= index + 1 %> + + + <%= country_flag(country['name']) %> + <%= country['name'] %> + +
+ <%= format_time_spent(country['minutes']) %> +
+ <% end %> +
+
+
+ <% end %> + + +
+
+

+ <%= icon 'earth' %> Countries & Cities +

+
+ <% if @digest.toponyms.present? %> + <% max_cities = @digest.toponyms.map { |country| country['cities']&.length || 0 }.max %> + <% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %> + + <% @digest.toponyms.each_with_index do |country, index| %> + <% cities_count = country['cities']&.length || 0 %> + <% progress_value = max_cities&.positive? ? (cities_count.to_f / max_cities * 100).round : 0 %> + <% color_class = progress_colors[index % progress_colors.length] %> + +
+
+ + <%= country_flag(country['country']) %> + <%= country['country'] %> + + + <%= pluralize(cities_count, 'city') %> + +
+ +
+ <% end %> + <% else %> +

No location data available

+ <% end %> +
+
+
+ + +
+
+

+ <%= icon 'trophy' %> All-Time Stats +

+
+
+
Countries visited
+
<%= @digest.total_countries_all_time %>
+
+
+
Cities explored
+
<%= @digest.total_cities_all_time %>
+
+
+
+
Total distance
+
<%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %>
+
+
+
+ + +
+ <%= link_to users_digests_path, class: 'btn btn-outline' do %> + Back to All Digests + <% end %> + +
+
+ + + + + diff --git a/app/views/users/digests_mailer/year_end_digest.html.erb b/app/views/users/digests_mailer/year_end_digest.html.erb new file mode 100644 index 00000000..66a6f4d5 --- /dev/null +++ b/app/views/users/digests_mailer/year_end_digest.html.erb @@ -0,0 +1,298 @@ + + + + + + + + +
+

<%= @digest.year %> Year in Review

+

Your journey, by the numbers

+
+ +
+

+ Hi, this is Evgenii from Dawarich! Pretty wild journey last year, huh? Let's take a look back at all the places you explored in <%= @digest.year %>. +

+
+ +
+ +
+
Distance Traveled
+

<%= distance_with_unit(@digest.distance, @distance_unit) %>

+

<%= distance_comparison_text(@digest.distance) %>

+ <% if @digest.yoy_distance_change %> +

+ <%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %> +

+ <% end %> +
+ + +
+
Countries Visited
+

<%= @digest.countries_count %>

+ <% if @digest.first_time_countries.any? %> +

+ New + First time in: <%= @digest.first_time_countries.join(', ') %> +

+ <% end %> +
+ + +
+
Cities Explored
+

<%= @digest.cities_count %>

+ <% if @digest.first_time_cities.any? %> +

+ New + <% cities_to_show = @digest.first_time_cities.take(5) %> + First time in: <%= cities_to_show.join(', ') %> + <% if @digest.first_time_cities.count > 5 %> + and <%= @digest.first_time_cities.count - 5 %> more + <% end %> +

+ <% end %> +
+ + + <% if @digest.monthly_distances.present? %> +
+

Your Year, Month by Month

+ <% max_distance = @digest.monthly_distances.values.map(&:to_i).max %> + <% max_distance = 1 if max_distance.zero? %> + <% chart_height = 120 %> + <% bar_colors = ['#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e'] %> + + + + <% (1..12).each do |month| %> + <% distance = @digest.monthly_distances[month.to_s].to_i %> + <% bar_height = (distance.to_f / max_distance * chart_height).round %> + <% bar_height = 3 if bar_height < 3 && distance > 0 %> + + <% end %> + + + + <% (1..12).each do |month| %> + + <% end %> + +
+
+
+ <%= Date::ABBR_MONTHNAMES[month][0..0] %> +
+
+ <% end %> + + + <% if @digest.top_countries_by_time.any? %> +
+
Where You Spent the Most Time
+
    + <% @digest.top_countries_by_time.take(3).each do |country| %> +
  • + <%= country_flag(country['name']) %> <%= country['name'] %> + <%= format_time_spent(country['minutes']) %> +
  • + <% end %> +
+
+ <% end %> + + + +
+ +
+

+ You can open your digest for sharing on its page on Dawarich: <%= users_digest_url(year: @digest.year) %> +

+
+ + + + diff --git a/app/views/users/digests_mailer/year_end_digest.text.erb b/app/views/users/digests_mailer/year_end_digest.text.erb new file mode 100644 index 00000000..cdb6b6af --- /dev/null +++ b/app/views/users/digests_mailer/year_end_digest.text.erb @@ -0,0 +1,41 @@ +<%= @digest.year %> Year in Review +==================================== + +Hi, this is Evgenii from Dawarich! Pretty wild journey last year, huh? Let's take a look back at all the places you explored in <%= @digest.year %>. + +DISTANCE TRAVELED +<%= distance_with_unit(@digest.distance, @distance_unit) %> +<%= distance_comparison_text(@digest.distance) %> +<% if @digest.yoy_distance_change %> +<%= yoy_change_text(@digest.yoy_distance_change) %> compared to <%= @digest.previous_year %> +<% end %> + +COUNTRIES VISITED: <%= @digest.countries_count %> +<% if @digest.first_time_countries.any? %> +First time in: <%= @digest.first_time_countries.join(', ') %> +<% end %> + +CITIES EXPLORED: <%= @digest.cities_count %> +<% if @digest.first_time_cities.any? %> +First time in: <%= @digest.first_time_cities.take(5).join(', ') %><% if @digest.first_time_cities.count > 5 %> and <%= @digest.first_time_cities.count - 5 %> more<% end %> +<% end %> + +<% if @digest.top_countries_by_time.any? %> +WHERE YOU SPENT THE MOST TIME +<% @digest.top_countries_by_time.take(3).each do |country| %> +- <%= country['name'] %>: <%= format_time_spent(country['minutes']) %> +<% end %> +<% end %> + +ALL-TIME STATS +- <%= @digest.total_countries_all_time %> countries visited +- <%= @digest.total_cities_all_time %> cities explored +- <%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %> traveled + +Keep exploring, keep discovering. Here's to even more adventures in <%= @digest.year + 1 %>! + +-- +Powered by Dawarich +https://dawarich.app + +Manage your email preferences: <%= settings_url(host: ENV.fetch('DOMAIN', 'localhost')) %> diff --git a/config/application.rb b/config/application.rb index bed4e260..55864862 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,6 +37,6 @@ module Dawarich config.active_job.queue_adapter = :sidekiq - config.action_mailer.preview_paths << "#{Rails.root.join('spec/mailers/previews')}" + config.action_mailer.preview_paths << Rails.root.join('spec/mailers/previews').to_s end end diff --git a/config/initializers/rails_pulse.rb b/config/initializers/rails_pulse.rb new file mode 100644 index 00000000..c4efebbc --- /dev/null +++ b/config/initializers/rails_pulse.rb @@ -0,0 +1,205 @@ +RailsPulse.configure do |config| + # ==================================================================================================== + # GLOBAL CONFIGURATION + # ==================================================================================================== + + # Enable or disable Rails Pulse + config.enabled = true + + # ==================================================================================================== + # THRESHOLDS + # ==================================================================================================== + # These thresholds are used to determine if a route, request, or query is slow, very slow, or critical. + # Values are in milliseconds (ms). Adjust these based on your application's performance requirements. + + # Thresholds for an individual route + config.route_thresholds = { + slow: 500, + very_slow: 1500, + critical: 3000 + } + + # Thresholds for an individual request + config.request_thresholds = { + slow: 700, + very_slow: 2000, + critical: 4000 + } + + # Thresholds for an individual database query + config.query_thresholds = { + slow: 100, + very_slow: 500, + critical: 1000 + } + + # ==================================================================================================== + # FILTERING + # ==================================================================================================== + + # Asset Tracking Configuration + # By default, Rails Pulse ignores asset requests (images, CSS, JS files) to focus on application performance. + # Set track_assets to true if you want to monitor asset delivery performance. + config.track_assets = false + + # Custom asset patterns to ignore (in addition to the built-in defaults) + # Only applies when track_assets is false. Add patterns for app-specific asset paths. + config.custom_asset_patterns = [ + # Example: ignore specific asset directories + # %r{^/uploads/}, + # %r{^/media/}, + # "/special-assets/" + ] + + # Rails Pulse Mount Path (optional) + # If Rails Pulse is mounted at a custom path, specify it here to prevent + # Rails Pulse from tracking its own requests. Leave as nil for default '/rails_pulse'. + # Examples: + # config.mount_path = "/admin/monitoring" + config.mount_path = nil + + # Manual route filtering + # Specify additional routes, requests, or queries to ignore from performance tracking. + # Each array can include strings (exact matches) or regular expressions. + # + # Examples: + # config.ignored_routes = ["/health_check", %r{^/admin}] + # config.ignored_requests = ["GET /status", %r{POST /api/v1/.*}] + # config.ignored_queries = ["SELECT 1", %r{FROM \"schema_migrations\"}] + + config.ignored_routes = [] + config.ignored_requests = [] + config.ignored_queries = [] + + # ==================================================================================================== + # TAGGING + # ==================================================================================================== + # Define custom tags for categorizing routes, requests, and queries. + # You can add any custom tags you want for filtering and organization. + # + # Tag names should be in present tense and describe the current state or category. + # Examples of good tag names: + # - "critical" (for high-priority endpoints) + # - "experimental" (for routes under development) + # - "deprecated" (for routes being phased out) + # - "external" (for third-party API calls) + # - "background" (for async job-related operations) + # - "admin" (for administrative routes) + # - "public" (for public-facing routes) + # + # Example configuration: + # config.tags = ["ignored", "critical", "experimental", "deprecated", "external", "admin"] + + config.tags = %w[ignored critical experimental] + + # ==================================================================================================== + # DATABASE CONFIGURATION + # ==================================================================================================== + # Configure Rails Pulse to use a separate database for performance monitoring data. + # This is optional but recommended for production applications to isolate performance + # data from your main application database. + # + # Uncomment and configure one of the following patterns: + + # Option 1: Separate single database for Rails Pulse + # config.connects_to = { + # database: { writing: :rails_pulse, reading: :rails_pulse } + # } + + # Option 2: Primary/replica configuration for Rails Pulse + # config.connects_to = { + # database: { writing: :rails_pulse_primary, reading: :rails_pulse_replica } + # } + + # Don't forget to add the database configuration to config/database.yml: + # + # production: + # # ... your main database config ... + # rails_pulse: + # adapter: postgresql # or mysql2, sqlite3 + # database: myapp_rails_pulse_production + # username: rails_pulse_user + # password: <%= Rails.application.credentials.dig(:rails_pulse, :database_password) %> + # host: localhost + # pool: 5 + + # ==================================================================================================== + # AUTHENTICATION + # ==================================================================================================== + # Configure authentication to secure access to the Rails Pulse dashboard. + # Authentication is ENABLED BY DEFAULT in production environments for security. + # + # If no authentication method is configured, Rails Pulse will use HTTP Basic Auth + # with credentials from RAILS_PULSE_USERNAME (default: 'admin') and RAILS_PULSE_PASSWORD + # environment variables. Set RAILS_PULSE_PASSWORD to enable this fallback. + # + # Uncomment and configure one of the following patterns based on your authentication system: + + # Enable/disable authentication (enabled by default in production) + config.authentication_enabled = true + + # Where to redirect unauthorized users + config.authentication_redirect_path = '/' + + # Custom authentication method - choose one of the examples below: + + # Example 1: Devise with admin role check + # config.authentication_method = proc { + # redirect_to main_app.root_path, alert: 'Access denied' unless user_signed_in? && current_user.admin? + # } + + # Example 2: Custom session-based authentication + # config.authentication_method = proc { + # unless session[:user_id] && User.find_by(id: session[:user_id])&.admin? + # redirect_to main_app.login_path, alert: "Please log in as an admin" + # end + # } + + # Example 3: Warden authentication + # config.authentication_method = proc { + # warden.authenticate!(:scope => :admin) + # } + + # Example 4: Basic HTTP authentication + config.authentication_method = proc { + authenticate_or_request_with_http_basic do |username, password| + username == ENV['RAILS_PULSE_USERNAME'] && password == ENV['RAILS_PULSE_PASSWORD'] + end + } + + # Example 5: Custom authorization check + # config.authentication_method = proc { + # current_user = User.find_by(id: session[:user_id]) + # unless current_user&.can_access_rails_pulse? + # render plain: "Forbidden", status: :forbidden + # end + # } + + # ==================================================================================================== + # DATA CLEANUP + # ==================================================================================================== + # Configure automatic cleanup of old performance data to manage database size. + # Rails Pulse provides two cleanup mechanisms that work together: + # + # 1. Time-based cleanup: Delete records older than the retention period + # 2. Count-based cleanup: Keep only the specified number of records per table + # + # Cleanup order respects foreign key constraints: + # operations → requests → queries/routes + + # Enable or disable automatic data cleanup + config.archiving_enabled = true + + # Time-based retention - delete records older than this period + config.full_retention_period = 2.weeks + + # Count-based retention - maximum records to keep per table + # After time-based cleanup, if tables still exceed these limits, + # the oldest remaining records will be deleted to stay under the limit + config.max_table_records = { + rails_pulse_requests: 10_000, # HTTP requests (moderate volume) + rails_pulse_operations: 50_000, # Operations within requests (high volume) + rails_pulse_routes: 1000, # Unique routes (low volume) + rails_pulse_queries: 500 # Normalized SQL queries (low volume) + } +end diff --git a/config/routes.rb b/config/routes.rb index 8ee7565d..ff40c143 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,6 +26,7 @@ Rails.application.routes.draw do } do mount Sidekiq::Web => '/sidekiq' end + mount RailsPulse::Engine => '/rails_pulse' # We want to return a nice error message if the user is not authorized to access Sidekiq match '/sidekiq' => redirect { |_, request| @@ -98,6 +99,17 @@ Rails.application.routes.draw do as: :sharing_stats, constraints: { year: /\d{4}/, month: /\d{1,2}/ } + # User digests routes (yearly/monthly digest reports) + scope module: 'users' do + resources :digests, only: %i[index create], param: :year, as: :users_digests + get 'digests/:year', to: 'digests#show', as: :users_digest, constraints: { year: /\d{4}/ } + end + get 'shared/digest/:uuid', to: 'shared/digests#show', as: :shared_users_digest + patch 'digests/:year/sharing', + to: 'shared/digests#update', + as: :sharing_users_digest, + constraints: { year: /\d{4}/ } + root to: 'home#index' get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success diff --git a/config/schedule.yml b/config/schedule.yml index ae920927..84cf04b1 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -49,3 +49,13 @@ nightly_family_invitations_cleanup_job: cron: "30 2 * * *" # every day at 02:30 class: "Family::Invitations::CleanupJob" queue: family + +rails_pulse_summary_job: + cron: "5 * * * *" # every hour at 5 minutes past the hour + class: "RailsPulse::SummaryJob" + queue: default + +rails_pulse_clean_up_job: + cron: "0 1 * * *" # every day at 01:00 + class: "RailsPulse::CleanupJob" + queue: default diff --git a/config/sidekiq.yml b/config/sidekiq.yml index a4464488..e7215709 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -17,3 +17,4 @@ - app_version_checking - cache - archival + - digests diff --git a/db/migrate/20251206000001_create_points_raw_data_archives.rb b/db/migrate/20251206000001_create_points_raw_data_archives.rb index 59990482..122abe2b 100644 --- a/db/migrate/20251206000001_create_points_raw_data_archives.rb +++ b/db/migrate/20251206000001_create_points_raw_data_archives.rb @@ -16,7 +16,7 @@ class CreatePointsRawDataArchives < ActiveRecord::Migration[8.0] end add_index :points_raw_data_archives, :user_id - add_index :points_raw_data_archives, [:user_id, :year, :month] + add_index :points_raw_data_archives, %i[user_id year month] add_index :points_raw_data_archives, :archived_at add_foreign_key :points_raw_data_archives, :users, validate: false end diff --git a/db/migrate/20251208210410_add_composite_index_to_stats.rb b/db/migrate/20251208210410_add_composite_index_to_stats.rb index 7f82a326..21ca64d7 100644 --- a/db/migrate/20251208210410_add_composite_index_to_stats.rb +++ b/db/migrate/20251208210410_add_composite_index_to_stats.rb @@ -3,16 +3,59 @@ class AddCompositeIndexToStats < ActiveRecord::Migration[8.0] disable_ddl_transaction! + BATCH_SIZE = 1000 + def change - # Add composite index for the most common stats lookup pattern: - # Stat.find_or_initialize_by(year:, month:, user:) - # This query is called on EVERY stats calculation - # - # Using algorithm: :concurrently to avoid locking the table during index creation - # This is crucial for production deployments with existing data + total_duplicates = execute(<<-SQL.squish).first['count'].to_i + SELECT COUNT(*) as count + FROM stats s1 + WHERE EXISTS ( + SELECT 1 FROM stats s2 + WHERE s2.user_id = s1.user_id + AND s2.year = s1.year + AND s2.month = s1.month + AND s2.id > s1.id + ) + SQL + + if total_duplicates.positive? + Rails.logger.info( + "Found #{total_duplicates} duplicate stats records. Starting cleanup in batches of #{BATCH_SIZE}..." + ) + end + + deleted_count = 0 + loop do + batch_deleted = execute(<<-SQL.squish).cmd_tuples + DELETE FROM stats + WHERE id IN ( + SELECT s1.id + FROM stats s1 + WHERE EXISTS ( + SELECT 1 FROM stats s2 + WHERE s2.user_id = s1.user_id + AND s2.year = s1.year + AND s2.month = s1.month + AND s2.id > s1.id + ) + LIMIT #{BATCH_SIZE} + ) + SQL + + break if batch_deleted.zero? + + deleted_count += batch_deleted + Rails.logger.info("Cleaned up #{deleted_count}/#{total_duplicates} duplicate stats records") + end + + Rails.logger.info("Completed cleanup: removed #{deleted_count} duplicate stats records") if deleted_count.positive? + add_index :stats, %i[user_id year month], name: 'index_stats_on_user_id_year_month', unique: true, - algorithm: :concurrently + algorithm: :concurrently, + if_not_exists: true + + BulkStatsCalculatingJob.perform_later end end diff --git a/db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb b/db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb index face565d..4f8b8366 100644 --- a/db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb +++ b/db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class AddVerifiedAtToPointsRawDataArchives < ActiveRecord::Migration[8.0] def change add_column :points_raw_data_archives, :verified_at, :datetime diff --git a/db/migrate/20251226170919_add_composite_index_to_points_user_id_timestamp.rb b/db/migrate/20251226170919_add_composite_index_to_points_user_id_timestamp.rb new file mode 100644 index 00000000..49fb0224 --- /dev/null +++ b/db/migrate/20251226170919_add_composite_index_to_points_user_id_timestamp.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddCompositeIndexToPointsUserIdTimestamp < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :points, %i[user_id timestamp], + order: { timestamp: :desc }, + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20251227000001_create_digests.rb b/db/migrate/20251227000001_create_digests.rb new file mode 100644 index 00000000..585c921d --- /dev/null +++ b/db/migrate/20251227000001_create_digests.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class CreateDigests < ActiveRecord::Migration[8.0] + def change + create_table :digests do |t| + t.references :user, null: false, foreign_key: true + t.integer :year, null: false + t.integer :period_type, null: false, default: 0 # enum: monthly: 0, yearly: 1 + + # Aggregated data + t.bigint :distance, null: false, default: 0 # Total distance in meters + t.jsonb :toponyms, default: {} # Countries/cities data + t.jsonb :monthly_distances, default: {} # {1: meters, 2: meters, ...} + t.jsonb :time_spent_by_location, default: {} # Top locations by time + + # First-time visits (calculated from historical data) + t.jsonb :first_time_visits, default: {} # {countries: [], cities: []} + + # Comparisons + t.jsonb :year_over_year, default: {} # {distance_change_percent: 15, ...} + t.jsonb :all_time_stats, default: {} # {total_countries: 50, ...} + + # Sharing (like Stat model) + t.jsonb :sharing_settings, default: {} + t.uuid :sharing_uuid + + # Email tracking + t.datetime :sent_at + + t.timestamps + end + + add_index :digests, %i[user_id year period_type], unique: true + add_index :digests, :sharing_uuid, unique: true + add_index :digests, :year + add_index :digests, :period_type + end +end diff --git a/db/migrate/20251227223614_change_digests_distance_to_bigint.rb b/db/migrate/20251227223614_change_digests_distance_to_bigint.rb new file mode 100644 index 00000000..9467fa39 --- /dev/null +++ b/db/migrate/20251227223614_change_digests_distance_to_bigint.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ChangeDigestsDistanceToBigint < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured { change_column :digests, :distance, :bigint, null: false, default: 0 } + end + + def down + safety_assured { change_column :digests, :distance, :integer, null: false, default: 0 } + end +end diff --git a/db/migrate/20251228000000_remove_unused_indexes.rb b/db/migrate/20251228000000_remove_unused_indexes.rb new file mode 100644 index 00000000..3c5f57e3 --- /dev/null +++ b/db/migrate/20251228000000_remove_unused_indexes.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class RemoveUnusedIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + remove_index :points, :geodata, algorithm: :concurrently, if_exists: true + remove_index :points, %i[latitude longitude], algorithm: :concurrently, if_exists: true + remove_index :points, :altitude, algorithm: :concurrently, if_exists: true + remove_index :points, :city, algorithm: :concurrently, if_exists: true + remove_index :points, :country_name, algorithm: :concurrently, if_exists: true + remove_index :points, :battery_status, algorithm: :concurrently, if_exists: true + remove_index :points, :connection, algorithm: :concurrently, if_exists: true + remove_index :points, :trigger, algorithm: :concurrently, if_exists: true + remove_index :points, :battery, algorithm: :concurrently, if_exists: true + remove_index :points, :country, algorithm: :concurrently, if_exists: true + remove_index :points, :external_track_id, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20251228100000_add_performance_indexes.rb b/db/migrate/20251228100000_add_performance_indexes.rb new file mode 100644 index 00000000..926463c1 --- /dev/null +++ b/db/migrate/20251228100000_add_performance_indexes.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class AddPerformanceIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + # Query: SELECT * FROM users WHERE api_key = $1 + add_index :users, :api_key, + algorithm: :concurrently, + if_not_exists: true + + # Query: SELECT id FROM users WHERE status = $1 + add_index :users, :status, + algorithm: :concurrently, + if_not_exists: true + + # Query: SELECT DISTINCT city FROM points WHERE user_id = $1 AND city IS NOT NULL + add_index :points, %i[user_id city], + name: 'idx_points_user_city', + algorithm: :concurrently, + if_not_exists: true + + # Query: SELECT 1 FROM points WHERE user_id = $1 AND visit_id IS NULL AND timestamp BETWEEN... + add_index :points, %i[user_id timestamp], + name: 'idx_points_user_visit_null_timestamp', + where: 'visit_id IS NULL', + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20251228163703_install_rails_pulse_tables.rb b/db/migrate/20251228163703_install_rails_pulse_tables.rb new file mode 100644 index 00000000..02548e36 --- /dev/null +++ b/db/migrate/20251228163703_install_rails_pulse_tables.rb @@ -0,0 +1,23 @@ +# Generated from Rails Pulse schema - automatically loads current schema definition +class InstallRailsPulseTables < ActiveRecord::Migration[8.0] + def change + # Load and execute the Rails Pulse schema directly + # This ensures the migration is always in sync with the schema file + schema_file = File.join(::Rails.root.to_s, "db/rails_pulse_schema.rb") + + if File.exist?(schema_file) + say "Loading Rails Pulse schema from db/rails_pulse_schema.rb" + + # Load the schema file to define RailsPulse::Schema + load schema_file + + # Execute the schema in the context of this migration + RailsPulse::Schema.call(connection) + + say "Rails Pulse tables created successfully" + say "The schema file db/rails_pulse_schema.rb remains as your single source of truth" + else + raise "Rails Pulse schema file not found at db/rails_pulse_schema.rb" + end + end +end \ No newline at end of file diff --git a/db/rails_pulse_migrate/.keep b/db/rails_pulse_migrate/.keep new file mode 100644 index 00000000..e69de29b diff --git a/db/rails_pulse_schema.rb b/db/rails_pulse_schema.rb new file mode 100644 index 00000000..71f54e3e --- /dev/null +++ b/db/rails_pulse_schema.rb @@ -0,0 +1,133 @@ +# Rails Pulse Database Schema +# This file contains the complete schema for Rails Pulse tables +# Load with: rails db:schema:load:rails_pulse or db:prepare + +RailsPulse::Schema = lambda do |connection| + # Skip if all tables already exist to prevent conflicts + required_tables = [ :rails_pulse_routes, :rails_pulse_queries, :rails_pulse_requests, :rails_pulse_operations, :rails_pulse_summaries ] + + if ENV["CI"] == "true" + existing_tables = required_tables.select { |table| connection.table_exists?(table) } + missing_tables = required_tables - existing_tables + puts "[RailsPulse::Schema] Existing tables: #{existing_tables.join(', ')}" if existing_tables.any? + puts "[RailsPulse::Schema] Missing tables: #{missing_tables.join(', ')}" if missing_tables.any? + end + + return if required_tables.all? { |table| connection.table_exists?(table) } + + connection.create_table :rails_pulse_routes do |t| + t.string :method, null: false, comment: "HTTP method (e.g., GET, POST)" + t.string :path, null: false, comment: "Request path (e.g., /posts/index)" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_routes, [ :method, :path ], unique: true, name: "index_rails_pulse_routes_on_method_and_path" + + connection.create_table :rails_pulse_queries do |t| + t.string :normalized_sql, limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" + t.datetime :analyzed_at, comment: "When query analysis was last performed" + t.text :explain_plan, comment: "EXPLAIN output from actual SQL execution" + t.text :issues, comment: "JSON array of detected performance issues" + t.text :metadata, comment: "JSON object containing query complexity metrics" + t.text :query_stats, comment: "JSON object with query characteristics analysis" + t.text :backtrace_analysis, comment: "JSON object with call chain and N+1 detection" + t.text :index_recommendations, comment: "JSON array of database index recommendations" + t.text :n_plus_one_analysis, comment: "JSON object with enhanced N+1 query detection results" + t.text :suggestions, comment: "JSON array of optimization recommendations" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_queries, :normalized_sql, unique: true, name: "index_rails_pulse_queries_on_normalized_sql", length: 191 + + connection.create_table :rails_pulse_requests do |t| + t.references :route, null: false, foreign_key: { to_table: :rails_pulse_routes }, comment: "Link to the route" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" + t.integer :status, null: false, comment: "HTTP status code (e.g., 200, 500)" + t.boolean :is_error, null: false, default: false, comment: "True if status >= 500" + t.string :request_uuid, null: false, comment: "Unique identifier for the request (e.g., UUID)" + t.string :controller_action, comment: "Controller and action handling the request (e.g., PostsController#show)" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.text :tags, comment: "JSON array of tags for filtering and categorization" + t.timestamps + end + + connection.add_index :rails_pulse_requests, :occurred_at, name: "index_rails_pulse_requests_on_occurred_at" + connection.add_index :rails_pulse_requests, :request_uuid, unique: true, name: "index_rails_pulse_requests_on_request_uuid" + connection.add_index :rails_pulse_requests, [ :route_id, :occurred_at ], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" + + connection.create_table :rails_pulse_operations do |t| + t.references :request, null: false, foreign_key: { to_table: :rails_pulse_requests }, comment: "Link to the request" + t.references :query, foreign_key: { to_table: :rails_pulse_queries }, index: true, comment: "Link to the normalized SQL query" + t.string :operation_type, null: false, comment: "Type of operation (e.g., database, view, gem_call)" + t.string :label, null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" + t.decimal :duration, precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" + t.string :codebase_location, comment: "File and line number (e.g., app/models/user.rb:25)" + t.float :start_time, null: false, default: 0.0, comment: "Operation start time in milliseconds" + t.timestamp :occurred_at, null: false, comment: "When the request started" + t.timestamps + end + + connection.add_index :rails_pulse_operations, :operation_type, name: "index_rails_pulse_operations_on_operation_type" + connection.add_index :rails_pulse_operations, :occurred_at, name: "index_rails_pulse_operations_on_occurred_at" + connection.add_index :rails_pulse_operations, [ :query_id, :occurred_at ], name: "index_rails_pulse_operations_on_query_and_time" + connection.add_index :rails_pulse_operations, [ :query_id, :duration, :occurred_at ], name: "index_rails_pulse_operations_query_performance" + connection.add_index :rails_pulse_operations, [ :occurred_at, :duration, :operation_type ], name: "index_rails_pulse_operations_on_time_duration_type" + + connection.create_table :rails_pulse_summaries do |t| + # Time fields + t.datetime :period_start, null: false, comment: "Start of the aggregation period" + t.datetime :period_end, null: false, comment: "End of the aggregation period" + t.string :period_type, null: false, comment: "Aggregation period type: hour, day, week, month" + + # Polymorphic association to handle both routes and queries + t.references :summarizable, polymorphic: true, null: false, index: true, comment: "Link to Route or Query" + # This creates summarizable_type (e.g., 'RailsPulse::Route', 'RailsPulse::Query') + # and summarizable_id (route_id or query_id) + + # Universal metrics + t.integer :count, default: 0, null: false, comment: "Total number of requests/operations" + t.float :avg_duration, comment: "Average duration in milliseconds" + t.float :min_duration, comment: "Minimum duration in milliseconds" + t.float :max_duration, comment: "Maximum duration in milliseconds" + t.float :p50_duration, comment: "50th percentile duration" + t.float :p95_duration, comment: "95th percentile duration" + t.float :p99_duration, comment: "99th percentile duration" + t.float :total_duration, comment: "Total duration in milliseconds" + t.float :stddev_duration, comment: "Standard deviation of duration" + + # Request/Route specific metrics + t.integer :error_count, default: 0, comment: "Number of error responses (5xx)" + t.integer :success_count, default: 0, comment: "Number of successful responses" + t.integer :status_2xx, default: 0, comment: "Number of 2xx responses" + t.integer :status_3xx, default: 0, comment: "Number of 3xx responses" + t.integer :status_4xx, default: 0, comment: "Number of 4xx responses" + t.integer :status_5xx, default: 0, comment: "Number of 5xx responses" + + t.timestamps + end + + # Unique constraint and indexes for summaries + connection.add_index :rails_pulse_summaries, [ :summarizable_type, :summarizable_id, :period_type, :period_start ], + unique: true, + name: "idx_pulse_summaries_unique" + connection.add_index :rails_pulse_summaries, [ :period_type, :period_start ], name: "index_rails_pulse_summaries_on_period" + connection.add_index :rails_pulse_summaries, :created_at, name: "index_rails_pulse_summaries_on_created_at" + + # Add indexes to existing tables for efficient aggregation + connection.add_index :rails_pulse_requests, [ :created_at, :route_id ], name: "idx_requests_for_aggregation" + connection.add_index :rails_pulse_requests, :created_at, name: "idx_requests_created_at" + + connection.add_index :rails_pulse_operations, [ :created_at, :query_id ], name: "idx_operations_for_aggregation" + connection.add_index :rails_pulse_operations, :created_at, name: "idx_operations_created_at" + + if ENV["CI"] == "true" + created_tables = required_tables.select { |table| connection.table_exists?(table) } + puts "[RailsPulse::Schema] Successfully created tables: #{created_tables.join(', ')}" + end +end + +if defined?(RailsPulse::ApplicationRecord) + RailsPulse::Schema.call(RailsPulse::ApplicationRecord.connection) +end diff --git a/db/schema.rb b/db/schema.rb index 0968224f..a5ddaf50 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_12_10_193532) do +ActiveRecord::Schema[8.0].define(version: 2025_12_28_163703) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -80,6 +80,29 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t| end + create_table "digests", force: :cascade do |t| + t.bigint "user_id", null: false + t.integer "year", null: false + t.integer "period_type", default: 0, null: false + t.bigint "distance", default: 0, null: false + t.jsonb "toponyms", default: {} + t.jsonb "monthly_distances", default: {} + t.jsonb "time_spent_by_location", default: {} + t.jsonb "first_time_visits", default: {} + t.jsonb "year_over_year", default: {} + t.jsonb "all_time_stats", default: {} + t.jsonb "sharing_settings", default: {} + t.uuid "sharing_uuid" + t.datetime "sent_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["period_type"], name: "index_digests_on_period_type" + t.index ["sharing_uuid"], name: "index_digests_on_sharing_uuid", unique: true + t.index ["user_id", "year", "period_type"], name: "index_digests_on_user_id_and_year_and_period_type", unique: true + t.index ["user_id"], name: "index_digests_on_user_id" + t.index ["year"], name: "index_digests_on_year" + end + create_table "exports", force: :cascade do |t| t.string "name", null: false t.string "url" @@ -226,18 +249,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do t.string "country_name" t.boolean "raw_data_archived", default: false, null: false t.bigint "raw_data_archive_id" - t.index ["altitude"], name: "index_points_on_altitude" - t.index ["battery"], name: "index_points_on_battery" - t.index ["battery_status"], name: "index_points_on_battery_status" - t.index ["city"], name: "index_points_on_city" - t.index ["connection"], name: "index_points_on_connection" - t.index ["country"], name: "index_points_on_country" t.index ["country_id"], name: "index_points_on_country_id" - t.index ["country_name"], name: "index_points_on_country_name" - t.index ["external_track_id"], name: "index_points_on_external_track_id" - t.index ["geodata"], name: "index_points_on_geodata", using: :gin t.index ["import_id"], name: "index_points_on_import_id" - t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude" t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist t.index ["raw_data_archive_id"], name: "index_points_on_raw_data_archive_id" @@ -245,10 +258,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at" 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", "city"], name: "idx_points_user_city" t.index ["user_id", "country_name"], name: "idx_points_user_country_name" t.index ["user_id", "reverse_geocoded_at"], name: "index_points_on_user_id_and_reverse_geocoded_at", where: "(reverse_geocoded_at IS NOT NULL)" t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation" + t.index ["user_id", "timestamp"], name: "idx_points_user_visit_null_timestamp", where: "(visit_id IS NULL)" + t.index ["user_id", "timestamp"], name: "index_points_on_user_id_and_timestamp", order: { timestamp: :desc } t.index ["user_id"], name: "index_points_on_user_id" t.index ["visit_id"], name: "index_points_on_visit_id" end @@ -270,6 +285,102 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do t.index ["user_id"], name: "index_points_raw_data_archives_on_user_id" end + create_table "rails_pulse_operations", force: :cascade do |t| + t.bigint "request_id", null: false, comment: "Link to the request" + t.bigint "query_id", comment: "Link to the normalized SQL query" + t.string "operation_type", null: false, comment: "Type of operation (e.g., database, view, gem_call)" + t.string "label", null: false, comment: "Descriptive name (e.g., SELECT FROM users WHERE id = 1, render layout)" + t.decimal "duration", precision: 15, scale: 6, null: false, comment: "Operation duration in milliseconds" + t.string "codebase_location", comment: "File and line number (e.g., app/models/user.rb:25)" + t.float "start_time", default: 0.0, null: false, comment: "Operation start time in milliseconds" + t.datetime "occurred_at", precision: nil, null: false, comment: "When the request started" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at", "query_id"], name: "idx_operations_for_aggregation" + t.index ["created_at"], name: "idx_operations_created_at" + t.index ["occurred_at", "duration", "operation_type"], name: "index_rails_pulse_operations_on_time_duration_type" + t.index ["occurred_at"], name: "index_rails_pulse_operations_on_occurred_at" + t.index ["operation_type"], name: "index_rails_pulse_operations_on_operation_type" + t.index ["query_id", "duration", "occurred_at"], name: "index_rails_pulse_operations_query_performance" + t.index ["query_id", "occurred_at"], name: "index_rails_pulse_operations_on_query_and_time" + t.index ["query_id"], name: "index_rails_pulse_operations_on_query_id" + t.index ["request_id"], name: "index_rails_pulse_operations_on_request_id" + end + + create_table "rails_pulse_queries", force: :cascade do |t| + t.string "normalized_sql", limit: 1000, null: false, comment: "Normalized SQL query string (e.g., SELECT * FROM users WHERE id = ?)" + t.datetime "analyzed_at", comment: "When query analysis was last performed" + t.text "explain_plan", comment: "EXPLAIN output from actual SQL execution" + t.text "issues", comment: "JSON array of detected performance issues" + t.text "metadata", comment: "JSON object containing query complexity metrics" + t.text "query_stats", comment: "JSON object with query characteristics analysis" + t.text "backtrace_analysis", comment: "JSON object with call chain and N+1 detection" + t.text "index_recommendations", comment: "JSON array of database index recommendations" + t.text "n_plus_one_analysis", comment: "JSON object with enhanced N+1 query detection results" + t.text "suggestions", comment: "JSON array of optimization recommendations" + t.text "tags", comment: "JSON array of tags for filtering and categorization" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["normalized_sql"], name: "index_rails_pulse_queries_on_normalized_sql", unique: true + end + + create_table "rails_pulse_requests", force: :cascade do |t| + t.bigint "route_id", null: false, comment: "Link to the route" + t.decimal "duration", precision: 15, scale: 6, null: false, comment: "Total request duration in milliseconds" + t.integer "status", null: false, comment: "HTTP status code (e.g., 200, 500)" + t.boolean "is_error", default: false, null: false, comment: "True if status >= 500" + t.string "request_uuid", null: false, comment: "Unique identifier for the request (e.g., UUID)" + t.string "controller_action", comment: "Controller and action handling the request (e.g., PostsController#show)" + t.datetime "occurred_at", precision: nil, null: false, comment: "When the request started" + t.text "tags", comment: "JSON array of tags for filtering and categorization" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at", "route_id"], name: "idx_requests_for_aggregation" + t.index ["created_at"], name: "idx_requests_created_at" + t.index ["occurred_at"], name: "index_rails_pulse_requests_on_occurred_at" + t.index ["request_uuid"], name: "index_rails_pulse_requests_on_request_uuid", unique: true + t.index ["route_id", "occurred_at"], name: "index_rails_pulse_requests_on_route_id_and_occurred_at" + t.index ["route_id"], name: "index_rails_pulse_requests_on_route_id" + end + + create_table "rails_pulse_routes", force: :cascade do |t| + t.string "method", null: false, comment: "HTTP method (e.g., GET, POST)" + t.string "path", null: false, comment: "Request path (e.g., /posts/index)" + t.text "tags", comment: "JSON array of tags for filtering and categorization" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["method", "path"], name: "index_rails_pulse_routes_on_method_and_path", unique: true + end + + create_table "rails_pulse_summaries", force: :cascade do |t| + t.datetime "period_start", null: false, comment: "Start of the aggregation period" + t.datetime "period_end", null: false, comment: "End of the aggregation period" + t.string "period_type", null: false, comment: "Aggregation period type: hour, day, week, month" + t.string "summarizable_type", null: false + t.bigint "summarizable_id", null: false, comment: "Link to Route or Query" + t.integer "count", default: 0, null: false, comment: "Total number of requests/operations" + t.float "avg_duration", comment: "Average duration in milliseconds" + t.float "min_duration", comment: "Minimum duration in milliseconds" + t.float "max_duration", comment: "Maximum duration in milliseconds" + t.float "p50_duration", comment: "50th percentile duration" + t.float "p95_duration", comment: "95th percentile duration" + t.float "p99_duration", comment: "99th percentile duration" + t.float "total_duration", comment: "Total duration in milliseconds" + t.float "stddev_duration", comment: "Standard deviation of duration" + t.integer "error_count", default: 0, comment: "Number of error responses (5xx)" + t.integer "success_count", default: 0, comment: "Number of successful responses" + t.integer "status_2xx", default: 0, comment: "Number of 2xx responses" + t.integer "status_3xx", default: 0, comment: "Number of 3xx responses" + t.integer "status_4xx", default: 0, comment: "Number of 4xx responses" + t.integer "status_5xx", default: 0, comment: "Number of 5xx responses" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_at"], name: "index_rails_pulse_summaries_on_created_at" + t.index ["period_type", "period_start"], name: "index_rails_pulse_summaries_on_period" + t.index ["summarizable_type", "summarizable_id", "period_type", "period_start"], name: "idx_pulse_summaries_unique", unique: true + t.index ["summarizable_type", "summarizable_id"], name: "index_rails_pulse_summaries_on_summarizable" + end + create_table "stats", force: :cascade do |t| t.integer "year", null: false t.integer "month", null: false @@ -372,9 +483,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do t.string "utm_campaign" t.string "utm_term" t.string "utm_content" + t.index ["api_key"], name: "index_users_on_api_key" t.index ["email"], name: "index_users_on_email", unique: true t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true + t.index ["status"], name: "index_users_on_status" end add_check_constraint "users", "admin IS NOT NULL", name: "users_admin_null", validate: false @@ -399,6 +512,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "areas", "users" + add_foreign_key "digests", "users" add_foreign_key "families", "users", column: "creator_id" add_foreign_key "family_invitations", "families" add_foreign_key "family_invitations", "users", column: "invited_by_id" @@ -411,6 +525,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do add_foreign_key "points", "users" add_foreign_key "points", "visits" add_foreign_key "points_raw_data_archives", "users" + add_foreign_key "rails_pulse_operations", "rails_pulse_queries", column: "query_id" + add_foreign_key "rails_pulse_operations", "rails_pulse_requests", column: "request_id" + add_foreign_key "rails_pulse_requests", "rails_pulse_routes", column: "route_id" add_foreign_key "stats", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "users" diff --git a/db/seeds.rb b/db/seeds.rb index 91330085..24a537a9 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -46,7 +46,7 @@ if Tag.none? { name: 'Home', color: '#FF5733', icon: '🏡' }, { name: 'Work', color: '#33FF57', icon: '💼' }, { name: 'Favorite', color: '#3357FF', icon: '⭐' }, - { name: 'Travel Plans', color: '#F1C40F', icon: '🗺️' }, + { name: 'Travel Plans', color: '#F1C40F', icon: '🗺️' } ] User.find_each do |user| diff --git a/e2e/v2/map/layers/advanced.spec.js b/e2e/v2/map/layers/advanced.spec.js index 965164a4..aa17ab46 100644 --- a/e2e/v2/map/layers/advanced.spec.js +++ b/e2e/v2/map/layers/advanced.spec.js @@ -36,6 +36,81 @@ test.describe('Advanced Layers', () => { expect(await fogToggle.isChecked()).toBe(true) }) + + test('fog radius setting can be changed and applied', async ({ page }) => { + // Enable fog layer first + await page.click('button[title="Open map settings"]') + await page.waitForTimeout(400) + await page.click('button[data-tab="layers"]') + await page.waitForTimeout(300) + + const fogToggle = page.locator('label:has-text("Fog of War")').first().locator('input.toggle') + await fogToggle.check() + await page.waitForTimeout(500) + + // Go to advanced settings tab + await page.click('button[data-tab="settings"]') + await page.waitForTimeout(300) + + // Find fog radius slider + const fogRadiusSlider = page.locator('input[name="fogOfWarRadius"]') + await expect(fogRadiusSlider).toBeVisible() + + // Change the slider value using evaluate to trigger input event + await fogRadiusSlider.evaluate((slider) => { + slider.value = '500' + slider.dispatchEvent(new Event('input', { bubbles: true })) + }) + await page.waitForTimeout(200) + + // Verify display value updated + const displayValue = page.locator('[data-maps--maplibre-target="fogRadiusValue"]') + await expect(displayValue).toHaveText('500m') + + // Verify slider value was set + expect(await fogRadiusSlider.inputValue()).toBe('500') + + // Click Apply Settings button + const applyButton = page.locator('button:has-text("Apply Settings")') + await applyButton.click() + await page.waitForTimeout(500) + + // Verify no errors in console + const consoleErrors = [] + page.on('console', msg => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + }) + await page.waitForTimeout(500) + expect(consoleErrors.filter(e => e.includes('fog_layer'))).toHaveLength(0) + }) + + test('fog settings can be applied without errors when fog layer is not visible', async ({ page }) => { + await page.click('button[title="Open map settings"]') + await page.waitForTimeout(400) + await page.click('button[data-tab="settings"]') + await page.waitForTimeout(300) + + // Change fog radius slider without enabling fog layer + const fogRadiusSlider = page.locator('input[name="fogOfWarRadius"]') + await fogRadiusSlider.evaluate((slider) => { + slider.value = '750' + slider.dispatchEvent(new Event('input', { bubbles: true })) + }) + await page.waitForTimeout(200) + + // Click Apply Settings - this should not throw an error + const applyButton = page.locator('button:has-text("Apply Settings")') + await applyButton.click() + await page.waitForTimeout(500) + + // Verify no JavaScript errors occurred + const consoleErrors = [] + page.on('console', msg => { + if (msg.type() === 'error') consoleErrors.push(msg.text()) + }) + await page.waitForTimeout(500) + expect(consoleErrors.filter(e => e.includes('undefined') || e.includes('fog'))).toHaveLength(0) + }) }) test.describe('Scratch Map', () => { diff --git a/e2e/v2/trips.spec.js b/e2e/v2/trips.spec.js new file mode 100644 index 00000000..d03f39f3 --- /dev/null +++ b/e2e/v2/trips.spec.js @@ -0,0 +1,100 @@ +import { test, expect } from '@playwright/test' +import { closeOnboardingModal } from '../helpers/navigation.js' + +test.describe('Trips Date Validation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/trips/new') + await closeOnboardingModal(page) + }); + + test('validates that start date is earlier than end date on new trip form', async ({ page }) => { + // Wait for the form to load + await page.waitForSelector('input[name="trip[started_at]"]') + + // Fill in trip name + await page.fill('input[name="trip[name]"]', 'Test Trip') + + // Set end date before start date + await page.fill('input[name="trip[started_at]"]', '2024-12-25T10:00') + await page.fill('input[name="trip[ended_at]"]', '2024-12-20T10:00') + + // Get the current URL to verify we stay on the same page + const currentUrl = page.url() + + // Try to submit the form + const submitButton = page.locator('input[type="submit"], button[type="submit"]') + await submitButton.click() + + // Wait a bit for potential navigation + await page.waitForTimeout(500) + + // Verify we're still on the same page (form wasn't submitted) + expect(page.url()).toBe(currentUrl) + + // Verify the dates are still there (form wasn't cleared) + const startValue = await page.locator('input[name="trip[started_at]"]').inputValue() + const endValue = await page.locator('input[name="trip[ended_at]"]').inputValue() + expect(startValue).toBe('2024-12-25T10:00') + expect(endValue).toBe('2024-12-20T10:00') + }); + + test('allows valid date range on new trip form', async ({ page }) => { + // Wait for the form to load + await page.waitForSelector('input[name="trip[started_at]"]') + + // Fill in trip name + await page.fill('input[name="trip[name]"]', 'Valid Test Trip') + + // Set valid date range (start before end) + await page.fill('input[name="trip[started_at]"]', '2024-12-20T10:00') + await page.fill('input[name="trip[ended_at]"]', '2024-12-25T10:00') + + // Trigger blur to validate + await page.locator('input[name="trip[ended_at]"]').blur() + + // Give the validation time to run + await page.waitForTimeout(200) + + // Check that the end date field has no validation error + const endDateInput = page.locator('input[name="trip[ended_at]"]') + const validationMessage = await endDateInput.evaluate(el => el.validationMessage) + const isValid = await endDateInput.evaluate(el => el.validity.valid) + + expect(validationMessage).toBe('') + expect(isValid).toBe(true) + }); + + test('validates dates when updating end date to be earlier than start date', async ({ page }) => { + // Wait for the form to load + await page.waitForSelector('input[name="trip[started_at]"]') + + // Fill in trip name + await page.fill('input[name="trip[name]"]', 'Test Trip') + + // First set a valid range + await page.fill('input[name="trip[started_at]"]', '2024-12-20T10:00') + await page.fill('input[name="trip[ended_at]"]', '2024-12-25T10:00') + + // Now change start date to be after end date + await page.fill('input[name="trip[started_at]"]', '2024-12-26T10:00') + + // Get the current URL to verify we stay on the same page + const currentUrl = page.url() + + // Try to submit the form + const submitButton = page.locator('input[type="submit"], button[type="submit"]') + await submitButton.click() + + // Wait a bit for potential navigation + await page.waitForTimeout(500) + + // Verify we're still on the same page (form wasn't submitted) + expect(page.url()).toBe(currentUrl) + + // Verify the dates are still there (form wasn't cleared) + const startValue = await page.locator('input[name="trip[started_at]"]').inputValue() + const endValue = await page.locator('input[name="trip[ended_at]"]').inputValue() + expect(startValue).toBe('2024-12-26T10:00') + expect(endValue).toBe('2024-12-25T10:00') + }); +}); diff --git a/lib/tasks/points_raw_data.rake b/lib/tasks/points_raw_data.rake index 0d5e60f2..68ff9b01 100644 --- a/lib/tasks/points_raw_data.rake +++ b/lib/tasks/points_raw_data.rake @@ -3,7 +3,7 @@ namespace :points do namespace :raw_data do desc 'Restore raw_data from archive to database for a specific month' - task :restore, [:user_id, :year, :month] => :environment do |_t, args| + task :restore, %i[user_id year month] => :environment do |_t, args| validate_args!(args) user_id = args[:user_id].to_i @@ -27,7 +27,7 @@ namespace :points do end desc 'Restore raw_data to memory/cache temporarily (for data migrations)' - task :restore_temporary, [:user_id, :year, :month] => :environment do |_t, args| + task :restore_temporary, %i[user_id year month] => :environment do |_t, args| validate_args!(args) user_id = args[:user_id].to_i @@ -69,9 +69,9 @@ namespace :points do puts '' archives = Points::RawDataArchive.where(user_id: user_id) - .select(:year, :month) - .distinct - .order(:year, :month) + .select(:year, :month) + .distinct + .order(:year, :month) puts "Found #{archives.count} months to restore" puts '' @@ -113,9 +113,9 @@ namespace :points do # Storage size via ActiveStorage total_blob_size = ActiveStorage::Blob - .joins('INNER JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id') - .where("active_storage_attachments.record_type = 'Points::RawDataArchive'") - .sum(:byte_size) + .joins('INNER JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id') + .where("active_storage_attachments.record_type = 'Points::RawDataArchive'") + .sum(:byte_size) puts "Storage used: #{ActiveSupport::NumberHelper.number_to_human_size(total_blob_size)}" puts '' @@ -130,10 +130,10 @@ namespace :points do puts '─────────────────────────────────────────────────' Points::RawDataArchive.group(:user_id) - .select('user_id, COUNT(*) as archive_count, SUM(point_count) as total_points') - .order('archive_count DESC') - .limit(10) - .each_with_index do |stat, idx| + .select('user_id, COUNT(*) as archive_count, SUM(point_count) as total_points') + .order('archive_count DESC') + .limit(10) + .each_with_index do |stat, idx| user = User.find(stat.user_id) puts "#{idx + 1}. #{user.email.ljust(30)} #{stat.archive_count.to_s.rjust(3)} archives, #{stat.total_points.to_s.rjust(8)} points" end @@ -142,7 +142,7 @@ namespace :points do end desc 'Verify archive integrity (all unverified archives, or specific month with args)' - task :verify, [:user_id, :year, :month] => :environment do |_t, args| + task :verify, %i[user_id year month] => :environment do |_t, args| verifier = Points::RawData::Verifier.new if args[:user_id] && args[:year] && args[:month] @@ -177,7 +177,7 @@ namespace :points do end desc 'Clear raw_data for verified archives (all verified, or specific month with args)' - task :clear_verified, [:user_id, :year, :month] => :environment do |_t, args| + task :clear_verified, %i[user_id year month] => :environment do |_t, args| clearer = Points::RawData::Clearer.new if args[:user_id] && args[:year] && args[:month] diff --git a/lib/tasks/webmanifest.rake b/lib/tasks/webmanifest.rake index 22264d3a..774fbe6a 100644 --- a/lib/tasks/webmanifest.rake +++ b/lib/tasks/webmanifest.rake @@ -1,6 +1,8 @@ +# frozen_string_literal: true + namespace :webmanifest do - desc "Generate site.webmanifest in public directory with correct asset paths" - task :generate => :environment do + desc 'Generate site.webmanifest in public directory with correct asset paths' + task generate: :environment do require 'erb' # Make sure assets are compiled first by loading the manifest @@ -12,28 +14,28 @@ namespace :webmanifest do # Generate the manifest content manifest_content = { - "name": "Dawarich", - "short_name": "Dawarich", + "name": 'Dawarich', + "short_name": 'Dawarich', "icons": [ { "src": icon_192_path, - "sizes": "192x192", - "type": "image/png" + "sizes": '192x192', + "type": 'image/png' }, { "src": icon_512_path, - "sizes": "512x512", - "type": "image/png" + "sizes": '512x512', + "type": 'image/png' } ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" + "theme_color": '#ffffff', + "background_color": '#ffffff', + "display": 'standalone' }.to_json # Write to public/site.webmanifest File.write(Rails.root.join('public/site.webmanifest'), manifest_content) - puts "Generated public/site.webmanifest with correct asset paths" + puts 'Generated public/site.webmanifest with correct asset paths' end end diff --git a/spec/factories/users/digests.rb b/spec/factories/users/digests.rb new file mode 100644 index 00000000..8d0182ab --- /dev/null +++ b/spec/factories/users/digests.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :users_digest, class: 'Users::Digest' do + year { 2024 } + period_type { :yearly } + distance { 500_000 } # 500 km + user + sharing_settings { {} } + sharing_uuid { SecureRandom.uuid } + + toponyms do + [ + { + 'country' => 'Germany', + 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] + }, + { + 'country' => 'France', + 'cities' => [{ 'city' => 'Paris' }] + }, + { + 'country' => 'Spain', + 'cities' => [{ 'city' => 'Madrid' }, { 'city' => 'Barcelona' }] + } + ] + end + + monthly_distances do + { + '1' => '50000', + '2' => '45000', + '3' => '60000', + '4' => '55000', + '5' => '40000', + '6' => '35000', + '7' => '30000', + '8' => '45000', + '9' => '50000', + '10' => '40000', + '11' => '25000', + '12' => '25000' + } + end + + time_spent_by_location do + { + 'countries' => [ + { 'name' => 'Germany', 'minutes' => 10_080 }, + { 'name' => 'France', 'minutes' => 4_320 }, + { 'name' => 'Spain', 'minutes' => 2_880 } + ], + 'cities' => [ + { 'name' => 'Berlin', 'minutes' => 5_040 }, + { 'name' => 'Paris', 'minutes' => 4_320 }, + { 'name' => 'Madrid', 'minutes' => 1_440 } + ] + } + end + + first_time_visits do + { + 'countries' => ['Spain'], + 'cities' => %w[Madrid Barcelona] + } + end + + year_over_year do + { + 'previous_year' => 2023, + 'distance_change_percent' => 15, + 'countries_change' => 1, + 'cities_change' => 2 + } + end + + all_time_stats do + { + 'total_countries' => 10, + 'total_cities' => 45, + 'total_distance' => '2500000' + } + end + + trait :with_sharing_enabled do + after(:create) do |digest, _evaluator| + digest.enable_sharing!(expiration: '24h') + 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 + + trait :sent do + sent_at { 1.day.ago } + end + + trait :monthly do + period_type { :monthly } + end + + trait :without_previous_year do + year_over_year { {} } + end + + trait :first_year do + first_time_visits do + { + 'countries' => %w[Germany France Spain], + 'cities' => ['Berlin', 'Paris', 'Madrid', 'Barcelona'] + } + end + year_over_year { {} } + end + end +end diff --git a/spec/jobs/imports/destroy_job_spec.rb b/spec/jobs/imports/destroy_job_spec.rb new file mode 100644 index 00000000..8492015e --- /dev/null +++ b/spec/jobs/imports/destroy_job_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Imports::DestroyJob, type: :job do + describe '#perform' do + let(:user) { create(:user) } + let(:import) { create(:import, user: user, status: :completed) } + + describe 'queue configuration' do + it 'uses the default queue' do + expect(described_class.queue_name).to eq('default') + end + end + + context 'when import exists' do + before do + create_list(:point, 3, user: user, import: import) + end + + it 'changes import status to deleting and deletes it' do + expect(import).not_to be_deleting + + import_id = import.id + described_class.perform_now(import_id) + + expect(Import.find_by(id: import_id)).to be_nil + end + + it 'calls the Imports::Destroy service' do + destroy_service = instance_double(Imports::Destroy) + allow(Imports::Destroy).to receive(:new).with(user, import).and_return(destroy_service) + allow(destroy_service).to receive(:call) + + described_class.perform_now(import.id) + + expect(Imports::Destroy).to have_received(:new).with(user, import) + expect(destroy_service).to have_received(:call) + end + + it 'broadcasts status update to the user' do + allow(ImportsChannel).to receive(:broadcast_to) + + described_class.perform_now(import.id) + + expect(ImportsChannel).to have_received(:broadcast_to).with( + user, + hash_including( + action: 'status_update', + import: hash_including( + id: import.id, + status: 'deleting' + ) + ) + ).at_least(:once) + end + + it 'broadcasts deletion complete to the user' do + allow(ImportsChannel).to receive(:broadcast_to) + + described_class.perform_now(import.id) + + expect(ImportsChannel).to have_received(:broadcast_to).with( + user, + hash_including( + action: 'delete', + import: hash_including(id: import.id) + ) + ).at_least(:once) + end + + it 'broadcasts both status update and deletion messages' do + allow(ImportsChannel).to receive(:broadcast_to) + + described_class.perform_now(import.id) + + expect(ImportsChannel).to have_received(:broadcast_to).twice + end + + it 'deletes the import and its points' do + import_id = import.id + point_ids = import.points.pluck(:id) + + described_class.perform_now(import_id) + + expect(Import.find_by(id: import_id)).to be_nil + expect(Point.where(id: point_ids)).to be_empty + end + end + + context 'when import does not exist' do + let(:non_existent_id) { 999_999 } + + it 'does not raise an error' do + expect { described_class.perform_now(non_existent_id) }.not_to raise_error + end + + it 'does not call the Imports::Destroy service' do + expect(Imports::Destroy).not_to receive(:new) + + described_class.perform_now(non_existent_id) + end + + it 'does not broadcast any messages' do + expect(ImportsChannel).not_to receive(:broadcast_to) + + described_class.perform_now(non_existent_id) + end + + it 'returns early without logging' do + allow(Rails.logger).to receive(:warn) + + described_class.perform_now(non_existent_id) + + expect(Rails.logger).not_to have_received(:warn) + end + end + + context 'when import is deleted during job execution' do + it 'handles RecordNotFound gracefully' do + allow(Import).to receive(:find_by).with(id: import.id).and_return(import) + allow(import).to receive(:deleting!).and_raise(ActiveRecord::RecordNotFound) + + expect { described_class.perform_now(import.id) }.not_to raise_error + end + + it 'logs a warning when RecordNotFound is raised' do + allow(Import).to receive(:find_by).with(id: import.id).and_return(import) + allow(import).to receive(:deleting!).and_raise(ActiveRecord::RecordNotFound) + allow(Rails.logger).to receive(:warn) + + described_class.perform_now(import.id) + + expect(Rails.logger).to have_received(:warn).with(/Import #{import.id} not found/) + end + end + + context 'when broadcast fails' do + before do + allow(ImportsChannel).to receive(:broadcast_to).and_raise(StandardError, 'Broadcast error') + end + + it 'allows the error to propagate' do + expect { described_class.perform_now(import.id) }.to raise_error(StandardError, 'Broadcast error') + end + end + + context 'when Imports::Destroy service fails' do + before do + allow_any_instance_of(Imports::Destroy).to receive(:call).and_raise(StandardError, 'Destroy failed') + end + + it 'allows the error to propagate' do + expect { described_class.perform_now(import.id) }.to raise_error(StandardError, 'Destroy failed') + end + + it 'has already set status to deleting before service is called' do + expect do + described_class.perform_now(import.id) + rescue StandardError + StandardError + end.to change { import.reload.status }.to('deleting') + end + end + + context 'with multiple imports for different users' do + let(:user2) { create(:user) } + let(:import2) { create(:import, user: user2, status: :completed) } + + it 'only broadcasts to the correct user' do + expect(ImportsChannel).to receive(:broadcast_to).with(user, anything).twice + expect(ImportsChannel).not_to receive(:broadcast_to).with(user2, anything) + + described_class.perform_now(import.id) + end + end + + context 'job enqueuing' do + it 'can be enqueued' do + expect do + described_class.perform_later(import.id) + end.to have_enqueued_job(described_class).with(import.id) + end + + it 'can be performed later with correct arguments' do + expect do + described_class.perform_later(import.id) + end.to have_enqueued_job(described_class).on_queue('default').with(import.id) + end + end + end +end diff --git a/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb b/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb index 28dbb9a5..b600f9f3 100644 --- a/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb +++ b/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb @@ -7,7 +7,6 @@ RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do let(:user) { create(:user) } before do - # Clear any existing jobs and points to ensure test isolation ActiveJob::Base.queue_adapter.enqueued_jobs.clear Point.delete_all end @@ -62,18 +61,22 @@ RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do end context 'with points needing reverse geocoding' do + let(:user2) { create(:user) } let!(:point_without_geocoding1) do create(:point, user: user, reverse_geocoded_at: nil) end let!(:point_without_geocoding2) do create(:point, user: user, reverse_geocoded_at: nil) end + let!(:point_without_geocoding3) do + create(:point, user: user2, reverse_geocoded_at: nil) + end let!(:geocoded_point) do create(:point, user: user, reverse_geocoded_at: 1.day.ago) end it 'processes all points that need reverse geocoding' do - expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times + expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(3).times end it 'enqueues jobs with correct parameters' do @@ -82,6 +85,8 @@ RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do .with('Point', point_without_geocoding1.id) .and have_enqueued_job(ReverseGeocodingJob) .with('Point', point_without_geocoding2.id) + .and have_enqueued_job(ReverseGeocodingJob) + .with('Point', point_without_geocoding3.id) end it 'uses find_each with correct batch size' do @@ -93,6 +98,47 @@ RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do expect(relation_mock).to have_received(:find_each).with(batch_size: 1000) end + + it 'invalidates caches for all affected users' do + allow(Cache::InvalidateUserCaches).to receive(:new).and_call_original + + described_class.perform_now + + # Verify that cache invalidation service was instantiated for both users + expect(Cache::InvalidateUserCaches).to have_received(:new).with(user.id) + expect(Cache::InvalidateUserCaches).to have_received(:new).with(user2.id) + end + + it 'invalidates caches for the correct users' do + cache_service1 = instance_double(Cache::InvalidateUserCaches) + cache_service2 = instance_double(Cache::InvalidateUserCaches) + + allow(Cache::InvalidateUserCaches).to receive(:new).with(user.id).and_return(cache_service1) + allow(Cache::InvalidateUserCaches).to receive(:new).with(user2.id).and_return(cache_service2) + allow(cache_service1).to receive(:call) + allow(cache_service2).to receive(:call) + + described_class.perform_now + + expect(cache_service1).to have_received(:call) + expect(cache_service2).to have_received(:call) + end + + it 'does not invalidate caches multiple times for the same user' do + cache_service = instance_double(Cache::InvalidateUserCaches) + + allow(Cache::InvalidateUserCaches).to receive(:new).with(user.id).and_return(cache_service) + allow(Cache::InvalidateUserCaches).to receive(:new).with(user2.id).and_return( + instance_double( + Cache::InvalidateUserCaches, call: nil + ) + ) + allow(cache_service).to receive(:call) + + described_class.perform_now + + expect(cache_service).to have_received(:call).once + end end end diff --git a/spec/jobs/users/digests/calculating_job_spec.rb b/spec/jobs/users/digests/calculating_job_spec.rb new file mode 100644 index 00000000..cb809236 --- /dev/null +++ b/spec/jobs/users/digests/calculating_job_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Digests::CalculatingJob, type: :job do + describe '#perform' do + let!(:user) { create(:user) } + let(:year) { 2024 } + + subject { described_class.perform_now(user.id, year) } + + before do + allow(Users::Digests::CalculateYear).to receive(:new).and_call_original + allow_any_instance_of(Users::Digests::CalculateYear).to receive(:call) + end + + it 'calls Users::Digests::CalculateYear service' do + subject + + expect(Users::Digests::CalculateYear).to have_received(:new).with(user.id, year) + end + + it 'enqueues to the digests queue' do + expect(described_class.new.queue_name).to eq('digests') + end + + context 'when Users::Digests::CalculateYear raises an error' do + before do + allow_any_instance_of(Users::Digests::CalculateYear).to receive(:call).and_raise(StandardError.new('Test error')) + end + + it 'creates an error notification' do + expect { subject }.to change { Notification.count }.by(1) + expect(Notification.last.kind).to eq('error') + expect(Notification.last.title).to include('Year-End Digest') + end + end + + context 'when user does not exist' do + before do + allow_any_instance_of(Users::Digests::CalculateYear).to receive(:call).and_raise(ActiveRecord::RecordNotFound) + end + + it 'does not raise error' do + expect { subject }.not_to raise_error + end + end + end +end diff --git a/spec/jobs/users/digests/email_sending_job_spec.rb b/spec/jobs/users/digests/email_sending_job_spec.rb new file mode 100644 index 00000000..c22e3a3b --- /dev/null +++ b/spec/jobs/users/digests/email_sending_job_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Digests::EmailSendingJob, type: :job do + describe '#perform' do + let!(:user) { create(:user) } + let(:year) { 2024 } + let!(:digest) { create(:users_digest, user: user, year: year, period_type: :yearly) } + + subject { described_class.perform_now(user.id, year) } + + before do + # Mock the mailer + allow(Users::DigestsMailer).to receive_message_chain(:with, :year_end_digest, :deliver_later) + end + + it 'enqueues to the mailers queue' do + expect(described_class.new.queue_name).to eq('mailers') + end + + context 'when user has digest emails enabled' do + it 'sends the email' do + subject + + expect(Users::DigestsMailer).to have_received(:with).with(user: user, digest: digest) + end + + it 'updates the sent_at timestamp' do + expect { subject }.to change { digest.reload.sent_at }.from(nil) + end + end + + context 'when user has digest emails disabled' do + before do + user.update!(settings: user.settings.merge('digest_emails_enabled' => false)) + end + + it 'does not send the email' do + subject + + expect(Users::DigestsMailer).not_to have_received(:with) + end + end + + context 'when digest does not exist' do + before { digest.destroy } + + it 'does not send the email' do + subject + + expect(Users::DigestsMailer).not_to have_received(:with) + end + end + + context 'when digest was already sent' do + before { digest.update!(sent_at: 1.day.ago) } + + it 'does not send the email again' do + subject + + expect(Users::DigestsMailer).not_to have_received(:with) + end + end + + context 'when user does not exist' do + before { user.destroy } + + it 'does not raise error' do + expect { described_class.perform_now(999_999, year) }.not_to raise_error + end + + it 'reports the exception' do + expect(ExceptionReporter).to receive(:call).with( + 'Users::Digests::EmailSendingJob', + anything + ) + + described_class.perform_now(999_999, year) + end + end + end +end diff --git a/spec/jobs/users/digests/year_end_scheduling_job_spec.rb b/spec/jobs/users/digests/year_end_scheduling_job_spec.rb new file mode 100644 index 00000000..59f0ccff --- /dev/null +++ b/spec/jobs/users/digests/year_end_scheduling_job_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Digests::YearEndSchedulingJob, type: :job do + describe '#perform' do + subject { described_class.perform_now } + + let(:previous_year) { Time.current.year - 1 } + + it 'enqueues to the digests queue' do + expect(described_class.new.queue_name).to eq('digests') + end + + context 'with users having different statuses' do + let!(:active_user) { create(:user, status: :active) } + let!(:trial_user) { create(:user, status: :trial) } + let!(:inactive_user) { create(:user) } + + before do + # Force inactive status after any after_commit callbacks + inactive_user.update_column(:status, 0) # inactive + + create(:stat, user: active_user, year: previous_year, month: 1) + create(:stat, user: trial_user, year: previous_year, month: 1) + create(:stat, user: inactive_user, year: previous_year, month: 1) + + allow(Users::Digests::CalculatingJob).to receive(:perform_later) + allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) + end + + it 'schedules jobs for active users' do + subject + + expect(Users::Digests::CalculatingJob).to have_received(:perform_later) + .with(active_user.id, previous_year) + end + + it 'schedules jobs for trial users' do + subject + + expect(Users::Digests::CalculatingJob).to have_received(:perform_later) + .with(trial_user.id, previous_year) + end + + it 'does not schedule jobs for inactive users' do + subject + + expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later) + .with(inactive_user.id, anything) + end + + it 'schedules email sending job with delay' do + email_job_double = double(perform_later: nil) + allow(Users::Digests::EmailSendingJob).to receive(:set) + .with(wait: 30.minutes) + .and_return(email_job_double) + + subject + + expect(Users::Digests::EmailSendingJob).to have_received(:set) + .with(wait: 30.minutes).at_least(:twice) + end + end + + context 'when user has no stats for previous year' do + let!(:user_without_stats) { create(:user, status: :active) } + let!(:user_with_stats) { create(:user, status: :active) } + + before do + create(:stat, user: user_with_stats, year: previous_year, month: 1) + + allow(Users::Digests::CalculatingJob).to receive(:perform_later) + allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) + end + + it 'does not schedule jobs for user without stats' do + subject + + expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later) + .with(user_without_stats.id, anything) + end + + it 'schedules jobs for user with stats' do + subject + + expect(Users::Digests::CalculatingJob).to have_received(:perform_later) + .with(user_with_stats.id, previous_year) + end + end + + context 'when user only has stats for current year' do + let!(:user_current_year_only) { create(:user, status: :active) } + + before do + create(:stat, user: user_current_year_only, year: Time.current.year, month: 1) + + allow(Users::Digests::CalculatingJob).to receive(:perform_later) + allow(Users::Digests::EmailSendingJob).to receive(:set).and_return(double(perform_later: nil)) + end + + it 'does not schedule jobs for that user' do + subject + + expect(Users::Digests::CalculatingJob).not_to have_received(:perform_later) + .with(user_current_year_only.id, anything) + end + end + end +end diff --git a/spec/mailers/previews/users/digests_mailer_preview.rb b/spec/mailers/previews/users/digests_mailer_preview.rb new file mode 100644 index 00000000..ec5b13a0 --- /dev/null +++ b/spec/mailers/previews/users/digests_mailer_preview.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Users::DigestsMailerPreview < ActionMailer::Preview + def year_end_digest + user = User.first + digest = user.digests.yearly.last || Users::Digest.last + + Users::DigestsMailer.with(user: user, digest: digest).year_end_digest + end +end diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb index 8c46a65a..7b79da46 100644 --- a/spec/models/trip_spec.rb +++ b/spec/models/trip_spec.rb @@ -7,6 +7,33 @@ RSpec.describe Trip, type: :model do it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_presence_of(:started_at) } it { is_expected.to validate_presence_of(:ended_at) } + + context 'date range validation' do + let(:user) { create(:user) } + + it 'is valid when started_at is before ended_at' do + trip = build(:trip, user: user, started_at: 1.day.ago, ended_at: Time.current) + expect(trip).to be_valid + end + + it 'is invalid when started_at is after ended_at' do + trip = build(:trip, user: user, started_at: Time.current, ended_at: 1.day.ago) + expect(trip).not_to be_valid + expect(trip.errors[:ended_at]).to include('must be after start date') + end + + it 'is invalid when started_at equals ended_at' do + time = Time.current + trip = build(:trip, user: user, started_at: time, ended_at: time) + expect(trip).not_to be_valid + expect(trip.errors[:ended_at]).to include('must be after start date') + end + + it 'is valid when both dates are blank during initialization' do + trip = Trip.new(user: user, name: 'Test Trip') + expect(trip.errors[:ended_at]).to be_empty + end + end end describe 'associations' do diff --git a/spec/models/users/digest_spec.rb b/spec/models/users/digest_spec.rb new file mode 100644 index 00000000..21e5487f --- /dev/null +++ b/spec/models/users/digest_spec.rb @@ -0,0 +1,429 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Digest, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:year) } + it { is_expected.to validate_presence_of(:period_type) } + + describe 'uniqueness of year within scope' do + let(:user) { create(:user) } + let!(:existing_digest) { create(:users_digest, user: user, year: 2024, period_type: :yearly) } + + it 'does not allow duplicate yearly digest for same user and year' do + duplicate = build(:users_digest, user: user, year: 2024, period_type: :yearly) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:year]).to include('has already been taken') + end + + it 'allows same year for different period types' do + monthly = build(:users_digest, user: user, year: 2024, period_type: :monthly) + expect(monthly).to be_valid + end + + it 'allows same year for different users' do + other_user = create(:user) + other_digest = build(:users_digest, user: other_user, year: 2024, period_type: :yearly) + expect(other_digest).to be_valid + end + end + end + + describe 'enums' do + it { is_expected.to define_enum_for(:period_type).with_values(monthly: 0, yearly: 1) } + end + + describe 'callbacks' do + describe 'before_create :generate_sharing_uuid' do + it 'generates a sharing_uuid if not present' do + digest = build(:users_digest, sharing_uuid: nil) + digest.save! + expect(digest.sharing_uuid).to be_present + end + + it 'does not overwrite existing sharing_uuid' do + existing_uuid = SecureRandom.uuid + digest = build(:users_digest, sharing_uuid: existing_uuid) + digest.save! + expect(digest.sharing_uuid).to eq(existing_uuid) + end + end + end + + describe 'helper methods' do + let(:user) { create(:user) } + let(:digest) { create(:users_digest, user: user) } + + describe '#countries_count' do + it 'returns count of countries from toponyms' do + expect(digest.countries_count).to eq(3) + end + + context 'when toponyms countries is nil' do + before { digest.update(toponyms: {}) } + + it 'returns 0' do + expect(digest.countries_count).to eq(0) + end + end + end + + describe '#cities_count' do + it 'returns count of cities from toponyms' do + expect(digest.cities_count).to eq(5) # Berlin, Munich, Paris, Madrid, Barcelona + end + + context 'when toponyms cities is nil' do + before { digest.update(toponyms: {}) } + + it 'returns 0' do + expect(digest.cities_count).to eq(0) + end + end + end + + describe '#first_time_countries' do + it 'returns first time countries' do + expect(digest.first_time_countries).to eq(['Spain']) + end + + context 'when first_time_visits countries is nil' do + before { digest.update(first_time_visits: {}) } + + it 'returns empty array' do + expect(digest.first_time_countries).to eq([]) + end + end + end + + describe '#first_time_cities' do + it 'returns first time cities' do + expect(digest.first_time_cities).to eq(%w[Madrid Barcelona]) + end + + context 'when first_time_visits cities is nil' do + before { digest.update(first_time_visits: {}) } + + it 'returns empty array' do + expect(digest.first_time_cities).to eq([]) + end + end + end + + describe '#top_countries_by_time' do + it 'returns countries sorted by time spent' do + expect(digest.top_countries_by_time.first['name']).to eq('Germany') + end + end + + describe '#top_cities_by_time' do + it 'returns cities sorted by time spent' do + expect(digest.top_cities_by_time.first['name']).to eq('Berlin') + end + end + + describe '#yoy_distance_change' do + it 'returns year over year distance change percent' do + expect(digest.yoy_distance_change).to eq(15) + end + + context 'when no previous year data' do + let(:digest) { create(:users_digest, :without_previous_year, user: user) } + + it 'returns nil' do + expect(digest.yoy_distance_change).to be_nil + end + end + end + + describe '#previous_year' do + it 'returns previous year' do + expect(digest.previous_year).to eq(2023) + end + end + + describe '#total_countries_all_time' do + it 'returns all time countries count' do + expect(digest.total_countries_all_time).to eq(10) + end + end + + describe '#total_cities_all_time' do + it 'returns all time cities count' do + expect(digest.total_cities_all_time).to eq(45) + end + end + + describe '#total_distance_all_time' do + it 'returns all time distance' do + expect(digest.total_distance_all_time).to eq(2_500_000) + end + end + + describe '#distance_km' do + it 'converts distance from meters to km' do + expect(digest.distance_km).to eq(500.0) + end + end + + describe '#distance_comparison_text' do + context 'when distance is less than Earth circumference' do + it 'returns Earth circumference comparison' do + expect(digest.distance_comparison_text).to include("Earth's circumference") + end + end + + context 'when distance is more than Moon distance' do + before { digest.update(distance: 500_000_000) } # 500k km + + it 'returns Moon distance comparison' do + expect(digest.distance_comparison_text).to include('Moon') + end + end + end + end + + describe 'sharing settings' do + let(:user) { create(:user) } + let(:digest) { create(:users_digest, user: user) } + + describe '#sharing_enabled?' do + context 'when sharing_settings is nil' do + before { digest.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(digest.sharing_enabled?).to be false + end + end + + context 'when sharing_settings is empty hash' do + before { digest.update(sharing_settings: {}) } + + it 'returns false' do + expect(digest.sharing_enabled?).to be false + end + end + + context 'when enabled is false' do + before { digest.update(sharing_settings: { 'enabled' => false }) } + + it 'returns false' do + expect(digest.sharing_enabled?).to be false + end + end + + context 'when enabled is true' do + before { digest.update(sharing_settings: { 'enabled' => true }) } + + it 'returns true' do + expect(digest.sharing_enabled?).to be true + end + end + + context 'when enabled is a string "true"' do + before { digest.update(sharing_settings: { 'enabled' => 'true' }) } + + it 'returns false (strict boolean check)' do + expect(digest.sharing_enabled?).to be false + end + end + end + + describe '#sharing_expired?' do + context 'when sharing_settings is nil' do + before { digest.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(digest.sharing_expired?).to be false + end + end + + context 'when expiration is blank' do + before { digest.update(sharing_settings: { 'enabled' => true }) } + + it 'returns false' do + expect(digest.sharing_expired?).to be false + end + end + + context 'when expiration is present but expires_at is blank' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h' + }) + end + + it 'returns true' do + expect(digest.sharing_expired?).to be true + end + end + + context 'when expires_at is in the future' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.from_now.iso8601 + }) + end + + it 'returns false' do + expect(digest.sharing_expired?).to be false + end + end + + context 'when expires_at is in the past' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + }) + end + + it 'returns true' do + expect(digest.sharing_expired?).to be true + end + end + + context 'when expires_at is invalid date string' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 'invalid-date' + }) + end + + it 'returns true (treats as expired)' do + expect(digest.sharing_expired?).to be true + end + end + end + + describe '#public_accessible?' do + context 'when sharing_settings is nil' do + before { digest.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(digest.public_accessible?).to be false + end + end + + context 'when sharing is not enabled' do + before { digest.update(sharing_settings: { 'enabled' => false }) } + + it 'returns false' do + expect(digest.public_accessible?).to be false + end + end + + context 'when sharing is enabled but expired' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + }) + end + + it 'returns false' do + expect(digest.public_accessible?).to be false + end + end + + context 'when sharing is enabled and not expired' do + before do + digest.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.from_now.iso8601 + }) + end + + it 'returns true' do + expect(digest.public_accessible?).to be true + end + end + + context 'when sharing is enabled with no expiration' do + before do + digest.update(sharing_settings: { 'enabled' => true }) + end + + it 'returns true' do + expect(digest.public_accessible?).to be true + end + end + end + + describe '#enable_sharing!' do + it 'enables sharing with default 24h expiration' do + digest.enable_sharing! + + expect(digest.sharing_enabled?).to be true + expect(digest.sharing_settings['expiration']).to eq('24h') + expect(digest.sharing_uuid).to be_present + end + + it 'enables sharing with custom expiration' do + digest.enable_sharing!(expiration: '1h') + + expect(digest.sharing_settings['expiration']).to eq('1h') + end + + it 'defaults to 24h for invalid expiration' do + digest.enable_sharing!(expiration: 'invalid') + + expect(digest.sharing_settings['expiration']).to eq('24h') + end + end + + describe '#disable_sharing!' do + before { digest.enable_sharing! } + + it 'disables sharing' do + digest.disable_sharing! + + expect(digest.sharing_enabled?).to be false + expect(digest.sharing_settings['expiration']).to be_nil + end + end + + describe '#generate_new_sharing_uuid!' do + it 'generates a new UUID' do + old_uuid = digest.sharing_uuid + digest.generate_new_sharing_uuid! + + expect(digest.sharing_uuid).not_to eq(old_uuid) + end + end + end + + describe 'DistanceConvertible' do + let(:user) { create(:user) } + let(:digest) { create(:users_digest, user: user, distance: 10_000) } # 10 km + + describe '#distance_in_unit' do + it 'converts distance to kilometers' do + expect(digest.distance_in_unit('km')).to eq(10.0) + end + + it 'converts distance to miles' do + expect(digest.distance_in_unit('mi').round(2)).to eq(6.21) + end + end + + describe '.convert_distance' do + it 'converts distance to kilometers' do + expect(described_class.convert_distance(10_000, 'km')).to eq(10.0) + end + end + end +end diff --git a/spec/requests/imports_spec.rb b/spec/requests/imports_spec.rb index 71ed302c..1e40366f 100644 --- a/spec/requests/imports_spec.rb +++ b/spec/requests/imports_spec.rb @@ -223,9 +223,10 @@ RSpec.describe 'Imports', type: :request do it 'deletes the import' do expect do delete import_path(import) - end.to change(user.imports, :count).by(-1) + end.to have_enqueued_job(Imports::DestroyJob).with(import.id) expect(response).to redirect_to(imports_path) + expect(import.reload).to be_deleting end end end diff --git a/spec/requests/shared/digests_spec.rb b/spec/requests/shared/digests_spec.rb new file mode 100644 index 00000000..57532117 --- /dev/null +++ b/spec/requests/shared/digests_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Shared::Digests', type: :request do + context 'public sharing' do + let(:user) { create(:user) } + let(:digest) { create(:users_digest, :with_sharing_enabled, user:, year: 2024) } + + describe 'GET /shared/digest/:uuid' do + context 'with valid sharing UUID' do + it 'renders the public year view' do + get shared_users_digest_url(digest.sharing_uuid) + + expect(response).to have_http_status(:success) + expect(response.body).to include('Year in Review') + expect(response.body).to include('2024') + end + + it 'includes required content in response' do + get shared_users_digest_url(digest.sharing_uuid) + + expect(response.body).to include('2024') + expect(response.body).to include('Distance traveled') + expect(response.body).to include('Countries visited') + end + end + + context 'with invalid sharing UUID' do + it 'redirects to root with alert' do + get shared_users_digest_url('invalid-uuid') + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared digest not found or no longer available') + end + end + + context 'with expired sharing' do + let(:digest) { create(:users_digest, :with_sharing_expired, user:, year: 2024) } + + it 'redirects to root with alert' do + get shared_users_digest_url(digest.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared digest not found or no longer available') + end + end + + context 'with disabled sharing' do + let(:digest) { create(:users_digest, :with_sharing_disabled, user:, year: 2024) } + + it 'redirects to root with alert' do + get shared_users_digest_url(digest.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared digest not found or no longer available') + end + end + end + + describe 'PATCH /digests/:year/sharing' do + context 'when user is signed in' do + let!(:digest_to_share) { create(:users_digest, user:, year: 2024) } + + before { sign_in user } + + context 'enabling sharing' do + it 'enables sharing and returns success' do + patch sharing_users_digest_path(year: 2024), + 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') + + digest_to_share.reload + expect(digest_to_share.sharing_enabled?).to be(true) + expect(digest_to_share.sharing_uuid).to be_present + end + + it 'sets custom expiration when provided' do + patch sharing_users_digest_path(year: 2024), + params: { enabled: '1', expiration: '12h' }, + as: :json + + expect(response).to have_http_status(:success) + digest_to_share.reload + expect(digest_to_share.sharing_enabled?).to be(true) + end + end + + context 'disabling sharing' do + let!(:enabled_digest) { create(:users_digest, :with_sharing_enabled, user:, year: 2023) } + + it 'disables sharing and returns success' do + patch sharing_users_digest_path(year: 2023), + 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_digest.reload + expect(enabled_digest.sharing_enabled?).to be(false) + end + end + + context 'when digest does not exist' do + it 'returns not found' do + patch sharing_users_digest_path(year: 2020), + 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_users_digest_path(year: 2024), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + end +end diff --git a/spec/requests/users/digests_spec.rb b/spec/requests/users/digests_spec.rb new file mode 100644 index 00000000..56e65ec0 --- /dev/null +++ b/spec/requests/users/digests_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe '/digests', type: :request do + context 'when user is not signed in' do + describe 'GET /index' do + it 'redirects to the sign in page' do + get users_digests_url + + expect(response.status).to eq(302) + end + end + + describe 'GET /show' do + it 'redirects to the sign in page' do + get users_digest_url(year: 2024) + + expect(response).to redirect_to(new_user_session_path) + end + end + + describe 'POST /create' do + it 'redirects to the sign in page' do + post users_digests_url, params: { year: 2024 } + + expect(response.status).to eq(302) + end + end + end + + context 'when user is signed in' do + let(:user) { create(:user) } + + before { sign_in user } + + describe 'GET /index' do + it 'renders a successful response' do + get users_digests_url + + expect(response.status).to eq(200) + end + + it 'displays existing digests' do + digest = create(:users_digest, user:, year: 2024) + + get users_digests_url + + expect(response.body).to include('2024') + end + + it 'shows empty state when no digests exist' do + get users_digests_url + + expect(response.body).to include('No Year-End Digests Yet') + end + end + + describe 'GET /show' do + let!(:digest) { create(:users_digest, user:, year: 2024) } + + it 'renders a successful response' do + get users_digest_url(year: 2024) + + expect(response.status).to eq(200) + end + + it 'includes digest content' do + get users_digest_url(year: 2024) + + expect(response.body).to include('2024 Year in Review') + expect(response.body).to include('Distance Traveled') + end + + it 'redirects when digest not found' do + get users_digest_url(year: 2020) + + expect(response).to redirect_to(users_digests_path) + expect(flash[:alert]).to eq('Digest not found') + end + end + + describe 'POST /create' do + context 'with valid year' do + before do + create(:stat, user:, year: 2024, month: 1) + end + + it 'enqueues Users::Digests::CalculatingJob' do + post users_digests_url, params: { year: 2024 } + + expect(Users::Digests::CalculatingJob).to have_been_enqueued.with(user.id, 2024) + end + + it 'redirects with success notice' do + post users_digests_url, params: { year: 2024 } + + expect(response).to redirect_to(users_digests_path) + expect(flash[:notice]).to include('is being generated') + end + end + + context 'with invalid year' do + it 'redirects with alert for year with no stats' do + post users_digests_url, params: { year: 2024 } + + expect(response).to redirect_to(users_digests_path) + expect(flash[:alert]).to eq('Invalid year selected') + end + + it 'redirects with alert for year before 2000' do + post users_digests_url, params: { year: 1999 } + + expect(response).to redirect_to(users_digests_path) + expect(flash[:alert]).to eq('Invalid year selected') + end + + it 'redirects with alert for future year' do + post users_digests_url, params: { year: Time.current.year + 1 } + + expect(response).to redirect_to(users_digests_path) + expect(flash[:alert]).to eq('Invalid year selected') + end + end + + context 'when user is inactive' do + before do + create(:stat, user:, year: 2024, month: 1) + user.update(status: :inactive, active_until: 1.day.ago) + end + + it 'returns an unauthorized response' do + post users_digests_url, params: { year: 2024 } + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('Your account is not active.') + end + end + end + end +end diff --git a/spec/services/cache/clean_spec.rb b/spec/services/cache/clean_spec.rb index 1d0ee55c..7be77a20 100644 --- a/spec/services/cache/clean_spec.rb +++ b/spec/services/cache/clean_spec.rb @@ -12,10 +12,10 @@ RSpec.describe Cache::Clean do 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" } + let(:user_1_countries_key) { "dawarich/user_#{user1.id}_countries_visited" } + let(:user_2_countries_key) { "dawarich/user_#{user2.id}_countries_visited" } + let(:user_1_cities_key) { "dawarich/user_#{user1.id}_cities_visited" } + let(:user_2_cities_key) { "dawarich/user_#{user2.id}_cities_visited" } before do # Set up cache entries that should be cleaned diff --git a/spec/services/cache/invalidate_user_caches_spec.rb b/spec/services/cache/invalidate_user_caches_spec.rb new file mode 100644 index 00000000..241a2d2a --- /dev/null +++ b/spec/services/cache/invalidate_user_caches_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Cache::InvalidateUserCaches do + let(:user) { create(:user) } + let(:service) { described_class.new(user.id) } + + describe '#call' do + it 'invalidates all user-related caches' do + Rails.cache.write("dawarich/user_#{user.id}_countries_visited", %w[USA Canada]) + Rails.cache.write("dawarich/user_#{user.id}_cities_visited", ['New York', 'Toronto']) + Rails.cache.write("dawarich/user_#{user.id}_points_geocoded_stats", { geocoded: 100, without_data: 10 }) + + expect(Rails.cache.read("dawarich/user_#{user.id}_countries_visited")).to eq(%w[USA Canada]) + expect(Rails.cache.read("dawarich/user_#{user.id}_cities_visited")).to eq(['New York', 'Toronto']) + expect(Rails.cache.read("dawarich/user_#{user.id}_points_geocoded_stats")).to eq({ geocoded: 100, +without_data: 10 }) + + service.call + + expect(Rails.cache.read("dawarich/user_#{user.id}_countries_visited")).to be_nil + expect(Rails.cache.read("dawarich/user_#{user.id}_cities_visited")).to be_nil + expect(Rails.cache.read("dawarich/user_#{user.id}_points_geocoded_stats")).to be_nil + end + end + + describe '#invalidate_countries_visited' do + it 'deletes the countries_visited cache' do + Rails.cache.write("dawarich/user_#{user.id}_countries_visited", %w[USA Canada]) + + service.invalidate_countries_visited + + expect(Rails.cache.read("dawarich/user_#{user.id}_countries_visited")).to be_nil + end + end + + describe '#invalidate_cities_visited' do + it 'deletes the cities_visited cache' do + Rails.cache.write("dawarich/user_#{user.id}_cities_visited", ['New York', 'Toronto']) + + service.invalidate_cities_visited + + expect(Rails.cache.read("dawarich/user_#{user.id}_cities_visited")).to be_nil + end + end + + describe '#invalidate_points_geocoded_stats' do + it 'deletes the points_geocoded_stats cache' do + Rails.cache.write("dawarich/user_#{user.id}_points_geocoded_stats", { geocoded: 100, without_data: 10 }) + + service.invalidate_points_geocoded_stats + + expect(Rails.cache.read("dawarich/user_#{user.id}_points_geocoded_stats")).to be_nil + end + end +end diff --git a/spec/services/stats/calculate_month_spec.rb b/spec/services/stats/calculate_month_spec.rb index 969926f9..dbb1928a 100644 --- a/spec/services/stats/calculate_month_spec.rb +++ b/spec/services/stats/calculate_month_spec.rb @@ -201,6 +201,27 @@ RSpec.describe Stats::CalculateMonth do end end end + + context 'when invalidating caches' do + it 'invalidates user caches after updating stats' do + cache_service = instance_double(Cache::InvalidateUserCaches) + allow(Cache::InvalidateUserCaches).to receive(:new).with(user.id).and_return(cache_service) + allow(cache_service).to receive(:call) + + calculate_stats + + expect(cache_service).to have_received(:call) + end + + it 'does not invalidate caches when there are no points' do + new_user = create(:user) + service = described_class.new(new_user.id, year, month) + + expect(Cache::InvalidateUserCaches).not_to receive(:new) + + service.call + end + end end end end diff --git a/spec/services/stats/hexagon_calculator_spec.rb b/spec/services/stats/hexagon_calculator_spec.rb index 0cf221ff..4036062d 100644 --- a/spec/services/stats/hexagon_calculator_spec.rb +++ b/spec/services/stats/hexagon_calculator_spec.rb @@ -62,6 +62,39 @@ RSpec.describe Stats::HexagonCalculator do expect(total_points).to eq(2) end + context 'when there are too many hexagons' do + let(:h3_resolution) { 15 } # Very high resolution to trigger MAX_HEXAGONS + + before do + # Stub to simulate too many hexagons on first call, then acceptable on second + allow_any_instance_of(described_class).to receive(:calculate_h3_indexes).and_call_original + call_count = 0 + allow_any_instance_of(described_class).to receive(:calculate_h3_indexes) do |instance, points, resolution| + call_count += 1 + if call_count == 1 + # First call: return too many hexagons + Hash.new.tap do |hash| + (described_class::MAX_HEXAGONS + 1).times do |i| + hash[i.to_s(16)] = [1, timestamp1, timestamp1] + end + end + else + # Second call with lower resolution: return acceptable amount + { '8c2a1072b3f1fff' => [2, timestamp1, timestamp2] } + end + end + end + + it 'recursively reduces resolution when too many hexagons are generated' do + result = calculate_hexagons + + expect(result).to be_an(Array) + expect(result).not_to be_empty + # Should have successfully reduced the hexagon count + expect(result.size).to be < described_class::MAX_HEXAGONS + end + end + context 'when H3 raises an error' do before do allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error') diff --git a/spec/services/users/digests/calculate_year_spec.rb b/spec/services/users/digests/calculate_year_spec.rb new file mode 100644 index 00000000..fe01120f --- /dev/null +++ b/spec/services/users/digests/calculate_year_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Digests::CalculateYear do + describe '#call' do + subject(:calculate_digest) { described_class.new(user.id, year).call } + + let(:user) { create(:user) } + let(:year) { 2024 } + + context 'when user has no stats for the year' do + it 'returns nil' do + expect(calculate_digest).to be_nil + end + + it 'does not create a digest' do + expect { calculate_digest }.not_to(change { Users::Digest.count }) + end + end + + context 'when user has stats for the year' do + let!(:january_stat) do + create(:stat, user: user, year: 2024, month: 1, distance: 50_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [ + { 'city' => 'Berlin', 'stayed_for' => 480 }, + { 'city' => 'Munich', 'stayed_for' => 240 } + ] } + ]) + end + + let!(:february_stat) do + create(:stat, user: user, year: 2024, month: 2, distance: 75_000, toponyms: [ + { 'country' => 'France', 'cities' => [ + { 'city' => 'Paris', 'stayed_for' => 360 } + ] } + ]) + end + + it 'creates a yearly digest' do + expect { calculate_digest }.to change { Users::Digest.count }.by(1) + end + + it 'returns the created digest' do + expect(calculate_digest).to be_a(Users::Digest) + end + + it 'sets the correct year' do + expect(calculate_digest.year).to eq(2024) + end + + it 'sets the period type to yearly' do + expect(calculate_digest.period_type).to eq('yearly') + end + + it 'calculates total distance' do + expect(calculate_digest.distance).to eq(125_000) + end + + it 'aggregates countries with their cities' do + toponyms = calculate_digest.toponyms + + countries = toponyms.map { |t| t['country'] } + expect(countries).to contain_exactly('France', 'Germany') + + germany = toponyms.find { |t| t['country'] == 'Germany' } + expect(germany['cities'].map { |c| c['city'] }).to contain_exactly('Berlin', 'Munich') + + france = toponyms.find { |t| t['country'] == 'France' } + expect(france['cities'].map { |c| c['city'] }).to contain_exactly('Paris') + end + + it 'builds monthly distances' do + expect(calculate_digest.monthly_distances['1']).to eq('50000') + expect(calculate_digest.monthly_distances['2']).to eq('75000') + expect(calculate_digest.monthly_distances['3']).to eq('0') # Missing month + end + + it 'calculates time spent by location' do + countries = calculate_digest.time_spent_by_location['countries'] + cities = calculate_digest.time_spent_by_location['cities'] + + expect(countries.first['name']).to eq('Germany') + expect(countries.first['minutes']).to eq(720) # 480 + 240 + expect(cities.first['name']).to eq('Berlin') + end + + it 'calculates all time stats' do + expect(calculate_digest.all_time_stats['total_distance']).to eq('125000') + end + + context 'when digest already exists' do + let!(:existing_digest) do + create(:users_digest, user: user, year: 2024, period_type: :yearly, distance: 10_000) + end + + it 'updates the existing digest' do + expect { calculate_digest }.not_to(change { Users::Digest.count }) + end + + it 'updates the distance' do + calculate_digest + expect(existing_digest.reload.distance).to eq(125_000) + end + end + end + + context 'with previous year data for comparison' do + let!(:previous_year_stat) do + create(:stat, user: user, year: 2023, month: 1, distance: 100_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + let!(:current_year_stat) do + create(:stat, user: user, year: 2024, month: 1, distance: 150_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] }, + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + end + + it 'calculates year over year comparison' do + expect(calculate_digest.year_over_year['previous_year']).to eq(2023) + expect(calculate_digest.year_over_year['distance_change_percent']).to eq(50) + end + + it 'identifies first time visits' do + expect(calculate_digest.first_time_visits['countries']).to eq(['France']) + expect(calculate_digest.first_time_visits['cities']).to eq(['Paris']) + end + end + + context 'when user not found' do + it 'raises ActiveRecord::RecordNotFound' do + expect do + described_class.new(999_999, year).call + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/users/digests/first_time_visits_calculator_spec.rb b/spec/services/users/digests/first_time_visits_calculator_spec.rb new file mode 100644 index 00000000..1fa12d16 --- /dev/null +++ b/spec/services/users/digests/first_time_visits_calculator_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Digests::FirstTimeVisitsCalculator do + describe '#call' do + subject(:calculator) { described_class.new(user, year).call } + + let(:user) { create(:user) } + let(:year) { 2024 } + + context 'when user has no previous years' do + let!(:current_year_stats) do + [ + create(:stat, user: user, year: 2024, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]), + create(:stat, user: user, year: 2024, month: 2, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + ] + end + + it 'returns all countries as first time' do + expect(calculator['countries']).to contain_exactly('France', 'Germany') + end + + it 'returns all cities as first time' do + expect(calculator['cities']).to contain_exactly('Berlin', 'Paris') + end + end + + context 'when user has previous years data' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + let!(:current_year_stats) do + [ + create(:stat, user: user, year: 2024, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] } + ]), + create(:stat, user: user, year: 2024, month: 2, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + ] + end + + it 'returns only new countries as first time' do + expect(calculator['countries']).to eq(['France']) + end + + it 'returns only new cities as first time' do + expect(calculator['cities']).to contain_exactly('Munich', 'Paris') + end + end + + context 'when user has multiple previous years' do + let!(:stats_2022) do + create(:stat, user: user, year: 2022, month: 1, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + let!(:stats_2023) do + create(:stat, user: user, year: 2023, month: 1, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, toponyms: [ + { 'country' => 'Spain', 'cities' => [{ 'city' => 'Madrid' }] }, + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + it 'considers all previous years when determining first time visits' do + expect(calculator['countries']).to eq(['Spain']) + expect(calculator['cities']).to eq(['Madrid']) + end + end + + context 'when user has no stats for current year' do + it 'returns empty arrays' do + expect(calculator['countries']).to eq([]) + expect(calculator['cities']).to eq([]) + end + end + + context 'when toponyms have invalid format' do + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, toponyms: nil) + end + + it 'handles nil toponyms gracefully' do + expect(calculator['countries']).to eq([]) + expect(calculator['cities']).to eq([]) + end + end + end +end diff --git a/spec/services/users/digests/year_over_year_calculator_spec.rb b/spec/services/users/digests/year_over_year_calculator_spec.rb new file mode 100644 index 00000000..6de1e913 --- /dev/null +++ b/spec/services/users/digests/year_over_year_calculator_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::Digests::YearOverYearCalculator do + describe '#call' do + subject(:calculator) { described_class.new(user, year).call } + + let(:user) { create(:user) } + let(:year) { 2024 } + + context 'when user has no previous year data' do + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000) + end + + it 'returns empty hash' do + expect(calculator).to eq({}) + end + end + + context 'when user has previous year data' do + let!(:previous_year_stats) do + [ + create(:stat, user: user, year: 2023, month: 1, distance: 50_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]), + create(:stat, user: user, year: 2023, month: 2, distance: 50_000, toponyms: [ + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + ] + end + + let!(:current_year_stats) do + [ + create(:stat, user: user, year: 2024, month: 1, distance: 75_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] } + ]), + create(:stat, user: user, year: 2024, month: 2, distance: 75_000, toponyms: [ + { 'country' => 'Spain', 'cities' => [{ 'city' => 'Madrid' }] } + ]) + ] + end + + it 'returns previous year' do + expect(calculator['previous_year']).to eq(2023) + end + + it 'calculates distance change percent' do + # Previous: 100,000m, Current: 150,000m = 50% increase + expect(calculator['distance_change_percent']).to eq(50) + end + + it 'calculates countries change' do + # Previous: 2 (Germany, France), Current: 2 (Germany, Spain) + expect(calculator['countries_change']).to eq(0) + end + + it 'calculates cities change' do + # Previous: 2 (Berlin, Paris), Current: 3 (Berlin, Munich, Madrid) + expect(calculator['cities_change']).to eq(1) + end + end + + context 'when distance decreased' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, distance: 200_000) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000) + end + + it 'returns negative distance change percent' do + expect(calculator['distance_change_percent']).to eq(-50) + end + end + + context 'when previous year distance is zero' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, distance: 0) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000) + end + + it 'returns nil for distance change percent' do + expect(calculator['distance_change_percent']).to be_nil + end + end + + context 'when countries and cities decreased' do + let!(:previous_year_stats) do + create(:stat, user: user, year: 2023, month: 1, distance: 100_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }, { 'city' => 'Munich' }] }, + { 'country' => 'France', 'cities' => [{ 'city' => 'Paris' }] } + ]) + end + + let!(:current_year_stats) do + create(:stat, user: user, year: 2024, month: 1, distance: 100_000, toponyms: [ + { 'country' => 'Germany', 'cities' => [{ 'city' => 'Berlin' }] } + ]) + end + + it 'returns negative countries change' do + expect(calculator['countries_change']).to eq(-1) + end + + it 'returns negative cities change' do + expect(calculator['cities_change']).to eq(-2) + end + end + end +end diff --git a/spec/services/users/safe_settings_spec.rb b/spec/services/users/safe_settings_spec.rb index 3c1c01e5..20411a94 100644 --- a/spec/services/users/safe_settings_spec.rb +++ b/spec/services/users/safe_settings_spec.rb @@ -25,12 +25,12 @@ RSpec.describe Users::SafeSettings do immich_api_key: nil, photoprism_url: nil, photoprism_api_key: nil, - maps: { "distance_unit" => "km" }, + maps: { 'distance_unit' => 'km' }, distance_unit: 'km', visits_suggestions_enabled: true, speed_color_scale: nil, fog_of_war_threshold: nil, - enabled_map_layers: ['Routes', 'Heatmap'], + enabled_map_layers: %w[Routes Heatmap], maps_maplibre_style: 'light' } ) @@ -56,7 +56,7 @@ RSpec.describe Users::SafeSettings do 'photoprism_api_key' => 'photoprism-key', 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }, 'visits_suggestions_enabled' => false, - 'enabled_map_layers' => ['Points', 'Routes', 'Areas', 'Photos'] + 'enabled_map_layers' => %w[Points Routes Areas Photos] } end let(:safe_settings) { described_class.new(settings) } @@ -64,24 +64,25 @@ RSpec.describe Users::SafeSettings do it 'returns custom configuration' do expect(safe_settings.settings).to eq( { - "fog_of_war_meters" => 100, - "meters_between_routes" => 1000, - "preferred_map_layer" => "Satellite", - "speed_colored_routes" => true, - "points_rendering_mode" => "simplified", - "minutes_between_routes" => 60, - "time_threshold_minutes" => 45, - "merge_threshold_minutes" => 20, - "live_map_enabled" => false, - "route_opacity" => 80, - "immich_url" => "https://immich.example.com", - "immich_api_key" => "immich-key", - "photoprism_url" => "https://photoprism.example.com", - "photoprism_api_key" => "photoprism-key", - "maps" => { "name" => "custom", "url" => "https://custom.example.com" }, - "visits_suggestions_enabled" => false, - "enabled_map_layers" => ['Points', 'Routes', 'Areas', 'Photos'], - "maps_maplibre_style" => "light" + 'fog_of_war_meters' => 100, + 'meters_between_routes' => 1000, + 'preferred_map_layer' => 'Satellite', + 'speed_colored_routes' => true, + 'points_rendering_mode' => 'simplified', + 'minutes_between_routes' => 60, + 'time_threshold_minutes' => 45, + 'merge_threshold_minutes' => 20, + 'live_map_enabled' => false, + 'route_opacity' => 80, + 'immich_url' => 'https://immich.example.com', + 'immich_api_key' => 'immich-key', + 'photoprism_url' => 'https://photoprism.example.com', + 'photoprism_api_key' => 'photoprism-key', + 'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }, + 'visits_suggestions_enabled' => false, + 'enabled_map_layers' => %w[Points Routes Areas Photos], + 'maps_maplibre_style' => 'light', + 'digest_emails_enabled' => true } ) end @@ -91,24 +92,24 @@ RSpec.describe Users::SafeSettings do { fog_of_war_meters: 100, meters_between_routes: 1000, - preferred_map_layer: "Satellite", + preferred_map_layer: 'Satellite', speed_colored_routes: true, - points_rendering_mode: "simplified", + points_rendering_mode: 'simplified', minutes_between_routes: 60, time_threshold_minutes: 45, merge_threshold_minutes: 20, live_map_enabled: false, route_opacity: 80, - immich_url: "https://immich.example.com", - immich_api_key: "immich-key", - photoprism_url: "https://photoprism.example.com", - photoprism_api_key: "photoprism-key", - maps: { "name" => "custom", "url" => "https://custom.example.com" }, + immich_url: 'https://immich.example.com', + immich_api_key: 'immich-key', + photoprism_url: 'https://photoprism.example.com', + photoprism_api_key: 'photoprism-key', + maps: { 'name' => 'custom', 'url' => 'https://custom.example.com' }, distance_unit: nil, visits_suggestions_enabled: false, speed_color_scale: nil, fog_of_war_threshold: nil, - enabled_map_layers: ['Points', 'Routes', 'Areas', 'Photos'], + enabled_map_layers: %w[Points Routes Areas Photos], maps_maplibre_style: 'light' } ) @@ -137,9 +138,9 @@ RSpec.describe Users::SafeSettings do expect(safe_settings.immich_api_key).to be_nil expect(safe_settings.photoprism_url).to be_nil expect(safe_settings.photoprism_api_key).to be_nil - expect(safe_settings.maps).to eq({ "distance_unit" => "km" }) + expect(safe_settings.maps).to eq({ 'distance_unit' => 'km' }) expect(safe_settings.visits_suggestions_enabled?).to be true - expect(safe_settings.enabled_map_layers).to eq(['Routes', 'Heatmap']) + expect(safe_settings.enabled_map_layers).to eq(%w[Routes Heatmap]) end end diff --git a/spec/views/devise/shared/_links.html.erb_spec.rb b/spec/views/devise/shared/_links.html.erb_spec.rb deleted file mode 100644 index 6972747b..00000000 --- a/spec/views/devise/shared/_links.html.erb_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe 'devise/shared/_links.html.erb', type: :view do - let(:resource_name) { :user } - let(:devise_mapping) { Devise.mappings[:user] } - - before do - def view.resource_name - :user - end - - def view.devise_mapping - Devise.mappings[:user] - end - - def view.resource_class - User - end - - def view.signed_in? - false - end - end - - context 'with OIDC provider' do - before do - stub_const('OMNIAUTH_PROVIDERS', [:openid_connect]) - allow(User).to receive(:omniauth_providers).and_return([:openid_connect]) - end - - it 'displays custom OIDC provider name' do - stub_const('OIDC_PROVIDER_NAME', 'Authentik') - - render - - expect(rendered).to have_button('Sign in with Authentik') - end - - it 'displays default name when OIDC_PROVIDER_NAME is not set' do - stub_const('OIDC_PROVIDER_NAME', 'Openid Connect') - - render - - expect(rendered).to have_button('Sign in with Openid Connect') - end - end - -end