diff --git a/CHANGELOG.md b/CHANGELOG.md index 5854fb91..ad662742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # [UNRELEASED] +The Search release + +In this release we're introducing a new search feature that allows users to search for places and see when they visited them. On the map page, click on Search icon, enter a place name (e.g. "Alexanderplatz"), wait for suggestions to load, and click on the suggestion you want to search for. You then will see a list of years you visited that place. Click on the year to unfold list of visits for that year. Then click on the visit you want to see on the map and you will be moved to that visit on the map. From the opened visit popup you can create a new visit to save it in the database. + +Important: This feature relies on reverse geocoding. Without reverse geocoding, the search feature will not work. + +## Added + +- User can now search for places and see when they visited them. + ## Fixed - Default value for `points_count` attribute is now set to 0 in the User model. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..febda52c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,219 @@ +# CLAUDE.md - Dawarich Development Guide + +This file contains essential information for Claude to work effectively with the Dawarich codebase. + +## Project Overview + +**Dawarich** is a self-hostable web application built with Ruby on Rails 8.0 that serves as a replacement for Google Timeline (Google Location History). It allows users to track, visualize, and analyze their location data through an interactive web interface. + +### Key Features +- Location history tracking and visualization +- Interactive maps with multiple layers (heatmap, points, lines, fog of war) +- Import from various sources (Google Maps Timeline, OwnTracks, Strava, GPX, GeoJSON, photos) +- Export to GeoJSON and GPX formats +- Statistics and analytics (countries visited, distance traveled, etc.) +- Trips management with photo integration +- Areas and visits tracking +- Integration with photo management systems (Immich, Photoprism) + +## Technology Stack + +### Backend +- **Framework**: Ruby on Rails 8.0 +- **Database**: PostgreSQL with PostGIS extension +- **Background Jobs**: Sidekiq with Redis +- **Authentication**: Devise +- **Authorization**: Pundit +- **API Documentation**: rSwag (Swagger) +- **Monitoring**: Prometheus, Sentry +- **File Processing**: AWS S3 integration + +### Frontend +- **CSS Framework**: Tailwind CSS with DaisyUI components +- **JavaScript**: Stimulus, Turbo Rails, Hotwired +- **Maps**: Leaflet.js +- **Charts**: Chartkick + +### Key Gems +- `activerecord-postgis-adapter` - PostgreSQL PostGIS support +- `geocoder` - Geocoding services +- `rgeo` - Ruby Geometric Library +- `gpx` - GPX file processing +- `parallel` - Parallel processing +- `sidekiq` - Background job processing +- `chartkick` - Chart generation + +## Project Structure + +``` +├── app/ +│ ├── controllers/ # Rails controllers +│ ├── models/ # ActiveRecord models with PostGIS support +│ ├── views/ # ERB templates +│ ├── services/ # Business logic services +│ ├── jobs/ # Sidekiq background jobs +│ ├── queries/ # Database query objects +│ ├── policies/ # Pundit authorization policies +│ ├── serializers/ # API response serializers +│ ├── javascript/ # Stimulus controllers and JS +│ └── assets/ # CSS and static assets +├── config/ # Rails configuration +├── db/ # Database migrations and seeds +├── docker/ # Docker configuration +├── spec/ # RSpec test suite +└── swagger/ # API documentation +``` + +## Core Models + +### Primary Models +- **User**: Authentication and user management +- **Point**: Individual location points with coordinates and timestamps +- **Track**: Collections of related points forming routes +- **Area**: Geographic areas drawn by users +- **Visit**: Detected visits to areas +- **Trip**: User-defined travel periods with analytics +- **Import**: Data import operations +- **Export**: Data export operations +- **Stat**: Calculated statistics and metrics + +### Geographic Features +- Uses PostGIS for advanced geographic queries +- Implements distance calculations and spatial relationships +- Supports various coordinate systems and projections + +## Development Environment + +### Setup +1. **Docker Development**: Use `docker-compose -f docker/docker-compose.yml up` +2. **DevContainer**: VS Code devcontainer support available +3. **Local Development**: + - `bundle exec rails db:prepare` + - `bundle exec sidekiq` (background jobs) + - `bundle exec bin/dev` (main application) + +### Default Credentials +- Username: `demo@dawarich.app` +- Password: `password` + +## Testing + +### Test Suite +- **Framework**: RSpec +- **System Tests**: Capybara + Selenium WebDriver +- **E2E Tests**: Playwright +- **Coverage**: SimpleCov +- **Factories**: FactoryBot +- **Mocking**: WebMock + +### Test Commands +```bash +bundle exec rspec # Run all specs +bundle exec rspec spec/models/ # Model specs only +npx playwright test # E2E tests +``` + +## Background Jobs + +### Sidekiq Jobs +- **Import Jobs**: Process uploaded location data files +- **Calculation Jobs**: Generate statistics and analytics +- **Notification Jobs**: Send user notifications +- **Photo Processing**: Extract EXIF data from photos + +### Key Job Classes +- `Tracks::ParallelGeneratorJob` - Generate track data in parallel +- Various import jobs for different data sources +- Statistical calculation jobs + +## API Documentation + +- **Framework**: rSwag (Swagger/OpenAPI) +- **Location**: `/api-docs` endpoint +- **Authentication**: JWT-based for API access + +## Database Schema + +### Key Tables +- `users` - User accounts and settings +- `points` - Location points with PostGIS geometry +- `tracks` - Route collections +- `areas` - User-defined geographic areas +- `visits` - Detected area visits +- `trips` - Travel periods +- `imports`/`exports` - Data transfer operations +- `stats` - Calculated metrics + +### PostGIS Integration +- Extensive use of PostGIS geometry types +- Spatial indexes for performance +- Geographic calculations and queries + +## Configuration + +### Environment Variables +See `.env.template` for available configuration options including: +- Database configuration +- Redis settings +- AWS S3 credentials +- External service integrations +- Feature flags + +### Key Config Files +- `config/database.yml` - Database configuration +- `config/sidekiq.yml` - Background job settings +- `config/schedule.yml` - Cron job schedules +- `docker/docker-compose.yml` - Development environment + +## Deployment + +### Docker +- Production: `docker/docker-compose.production.yml` +- Development: `docker/docker-compose.yml` +- Multi-stage Docker builds supported + +### Procfiles +- `Procfile` - Production Heroku deployment +- `Procfile.dev` - Development with Foreman +- `Procfile.production` - Production processes + +## Code Quality + +### Tools +- **Linting**: RuboCop with Rails extensions +- **Security**: Brakeman, bundler-audit +- **Dependencies**: Strong Migrations for safe database changes +- **Performance**: Stackprof for profiling + +### Commands +```bash +bundle exec rubocop # Code linting +bundle exec brakeman # Security scan +bundle exec bundle-audit # Dependency security +``` + +## Important Notes for Development + +1. **Location Data**: Always handle location data with appropriate precision and privacy considerations +2. **PostGIS**: Leverage PostGIS features for geographic calculations rather than Ruby-based solutions +2.1 **Coordinates**: Use `lonlat` column in `points` table for geographic calculations +3. **Background Jobs**: Use Sidekiq for any potentially long-running operations +4. **Testing**: Include both unit and integration tests for location-based features +5. **Performance**: Consider database indexes for geographic queries +6. **Security**: Never log or expose user location data inappropriately + +## Contributing + +- **Main Branch**: `master` +- **Development**: `dev` branch for pull requests +- **Issues**: GitHub Issues for bug reports +- **Discussions**: GitHub Discussions for feature requests +- **Community**: Discord server for questions + +## Resources + +- **Documentation**: https://dawarich.app/docs/ +- **Repository**: https://github.com/Freika/dawarich +- **Discord**: https://discord.gg/pHsBjpt5J8 +- **Changelog**: See CHANGELOG.md for version history +- **Development Setup**: See DEVELOPMENT.md diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index bee31ae9..2b37c492 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}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-2{margin:.5rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-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-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-opacity-30{--tw-border-opacity:0.3}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.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}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-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-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact -.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.placeholder\:text-base-content\/50::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.placeholder\:text-base-content\/50::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}} \ No newline at end of file + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-2{margin:.5rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-opacity-30{--tw-border-opacity:0.3}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/50{color:var(--fallback-bc,oklch(var(--bc)/.5))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact +.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.placeholder\:text-base-content\/50::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.placeholder\:text-base-content\/50::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.5))}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}} \ No newline at end of file diff --git a/app/controllers/api/v1/locations_controller.rb b/app/controllers/api/v1/locations_controller.rb new file mode 100644 index 00000000..1245186c --- /dev/null +++ b/app/controllers/api/v1/locations_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class Api::V1::LocationsController < ApiController + before_action :validate_search_params, only: [:index] + before_action :validate_suggestion_params, only: [:suggestions] + + def index + if coordinate_search? + search_results = LocationSearch::PointFinder.new(current_api_user, search_params).call + + render json: Api::LocationSearchResultSerializer.new(search_results).call + else + render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request + end + rescue StandardError => e + Rails.logger.error "Location search error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + render json: { error: 'Search failed. Please try again.' }, status: :internal_server_error + end + + def suggestions + if search_query.present? && search_query.length >= 2 + suggestions = LocationSearch::GeocodingService.new(search_query).search + + # Format suggestions for the frontend + formatted_suggestions = suggestions.map do |suggestion| + { + name: suggestion[:name], + address: suggestion[:address], + coordinates: [suggestion[:lat], suggestion[:lon]], + type: suggestion[:type] + } + end + + render json: { suggestions: formatted_suggestions } + else + render json: { suggestions: [] } + end + rescue StandardError => e + Rails.logger.error "Suggestions error: #{e.message}" + render json: { suggestions: [] } + end + + private + + def search_query + params[:q]&.strip + end + + def search_params + { + latitude: params[:lat]&.to_f, + longitude: params[:lon]&.to_f, + limit: params[:limit]&.to_i || 50, + date_from: parse_date(params[:date_from]), + date_to: parse_date(params[:date_to]), + radius_override: params[:radius_override]&.to_i + } + end + + def coordinate_search? + params[:lat].present? && params[:lon].present? + end + + def validate_search_params + unless coordinate_search? + render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request + return false + end + + lat = params[:lat]&.to_f + lon = params[:lon]&.to_f + + if lat.abs > 90 || lon.abs > 180 + render json: { error: 'Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180' }, + status: :bad_request + return false + end + + true + end + + def validate_suggestion_params + if search_query.present? && search_query.length > 200 + render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request + return false + end + + true + end + + def parse_date(date_string) + return nil if date_string.blank? + + Date.parse(date_string) + rescue ArgumentError + nil + end +end diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index bbb308bb..e0b6f8be 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -36,6 +36,8 @@ class MapController < ApplicationController end def calculate_distance + return 0 if @coordinates.size < 2 + total_distance = 0 @coordinates.each_cons(2) do diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index bdbcbe40..f45fe68a 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -36,6 +36,7 @@ import { fetchAndDisplayPhotos } from "../maps/photos"; import { countryCodesMap } from "../maps/country_codes"; import { VisitsManager } from "../maps/visits"; import { ScratchLayer } from "../maps/scratch_layer"; +import { LocationSearch } from "../maps/location_search"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; @@ -189,6 +190,9 @@ export default class extends BaseController { // Initialize the visits manager this.visitsManager = new VisitsManager(this.map, this.apiKey); + + // Expose visits manager globally for location search integration + window.visitsManager = this.visitsManager; // Initialize layers for the layer control const controlsLayer = { @@ -239,6 +243,9 @@ export default class extends BaseController { // Initialize Live Map Handler this.initializeLiveMapHandler(); + + // Initialize Location Search + this.initializeLocationSearch(); } disconnect() { @@ -1824,4 +1831,10 @@ export default class extends BaseController { toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible); } } + + initializeLocationSearch() { + if (this.map && this.apiKey) { + this.locationSearch = new LocationSearch(this.map, this.apiKey); + } + } } diff --git a/app/javascript/maps/location_search.js b/app/javascript/maps/location_search.js new file mode 100644 index 00000000..b3959bb5 --- /dev/null +++ b/app/javascript/maps/location_search.js @@ -0,0 +1,1206 @@ +// Location search functionality for the map +class LocationSearch { + constructor(map, apiKey) { + this.map = map; + this.apiKey = apiKey; + this.searchResults = []; + this.searchMarkersLayer = null; + this.currentSearchQuery = ''; + this.searchTimeout = null; + this.suggestionsVisible = false; + this.currentSuggestionIndex = -1; + + // Make instance globally accessible for popup buttons + window.locationSearchInstance = this; + + this.initializeSearchBar(); + } + + initializeSearchBar() { + // Create search toggle button using Leaflet control (positioned below settings button) + const SearchToggleControl = L.Control.extend({ + onAdd: function(map) { + const button = L.DomUtil.create('button', 'location-search-toggle'); + button.innerHTML = '🔍'; + button.style.width = '48px'; + button.style.height = '48px'; + button.style.border = 'none'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + button.style.backgroundColor = 'white'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.fontSize = '18px'; + button.style.marginTop = '10px'; // Space below settings button + button.title = 'Search locations'; + button.id = 'location-search-toggle'; + return button; + } + }); + + // Add the search toggle control to the map + this.map.addControl(new SearchToggleControl({ position: 'topleft' })); + + // Use setTimeout to ensure the DOM element is available + setTimeout(() => { + // Get reference to the created button + const toggleButton = document.getElementById('location-search-toggle'); + + if (toggleButton) { + // Create inline search bar + this.createInlineSearchBar(); + + // Store references + this.toggleButton = toggleButton; + this.searchVisible = false; + + // Bind events + this.bindSearchEvents(); + + console.log('LocationSearch: Search button initialized successfully'); + } else { + console.error('LocationSearch: Could not find search toggle button'); + } + }, 100); + } + + createInlineSearchBar() { + // Create inline search bar that appears next to the search button + const searchBar = document.createElement('div'); + searchBar.className = 'location-search-bar absolute bg-white border border-gray-300 rounded-lg shadow-lg hidden'; + searchBar.id = 'location-search-container'; // Use container ID for test compatibility + searchBar.style.width = '400px'; // Increased width for better usability + searchBar.style.maxHeight = '600px'; // Set max height for the entire search bar + searchBar.style.padding = '12px'; // Increased padding + searchBar.style.zIndex = '9999'; // Very high z-index to ensure visibility + searchBar.style.overflow = 'visible'; // Allow content to overflow but results area will scroll + + searchBar.innerHTML = ` +
+ + + +
+ + + + + + + `; + + // Add search bar to the map container + this.map.getContainer().appendChild(searchBar); + + // Store references + this.searchBar = searchBar; + this.searchInput = document.getElementById('location-search-input'); + this.searchButton = document.getElementById('location-search-submit'); + this.closeButton = document.getElementById('location-search-close'); + this.suggestionsContainer = document.getElementById('location-search-suggestions'); + this.suggestionsPanel = document.getElementById('location-search-suggestions-panel'); + this.resultsContainer = document.getElementById('location-search-results'); + this.resultsPanel = document.getElementById('location-search-results-panel'); + + // Set scrolling properties immediately for results container with !important + this.resultsContainer.style.setProperty('max-height', '400px', 'important'); + this.resultsContainer.style.setProperty('overflow-y', 'scroll', 'important'); + this.resultsContainer.style.setProperty('overflow-x', 'hidden', 'important'); + this.resultsContainer.style.setProperty('min-height', '0', 'important'); + this.resultsContainer.style.setProperty('display', 'block', 'important'); + + // Set scrolling properties for suggestions container with !important + this.suggestionsContainer.style.setProperty('max-height', '200px', 'important'); + this.suggestionsContainer.style.setProperty('overflow-y', 'scroll', 'important'); + this.suggestionsContainer.style.setProperty('overflow-x', 'hidden', 'important'); + this.suggestionsContainer.style.setProperty('min-height', '0', 'important'); + this.suggestionsContainer.style.setProperty('display', 'block', 'important'); + + console.log('LocationSearch: Set scrolling properties on containers'); + + // Prevent map scroll events when scrolling inside the search containers + this.preventMapScrollOnContainers(); + + // No clear button or default panel in inline mode + this.clearButton = null; + this.defaultPanel = null; + } + + preventMapScrollOnContainers() { + // Prevent scroll events from bubbling to the map when scrolling inside search containers + const containers = [this.resultsContainer, this.suggestionsContainer, this.searchBar]; + + containers.forEach(container => { + if (container) { + // Prevent wheel events (scroll) from reaching the map + container.addEventListener('wheel', (e) => { + e.stopPropagation(); + }, { passive: false }); + + // Prevent touch scroll events from reaching the map + container.addEventListener('touchstart', (e) => { + e.stopPropagation(); + }, { passive: false }); + + container.addEventListener('touchmove', (e) => { + e.stopPropagation(); + }, { passive: false }); + + container.addEventListener('touchend', (e) => { + e.stopPropagation(); + }, { passive: false }); + + // Also prevent mousewheel for older browsers + container.addEventListener('mousewheel', (e) => { + e.stopPropagation(); + }, { passive: false }); + + // Prevent DOMMouseScroll for Firefox + container.addEventListener('DOMMouseScroll', (e) => { + e.stopPropagation(); + }, { passive: false }); + + console.log('LocationSearch: Added scroll prevention to container', container.id || 'search-bar'); + } + }); + } + + bindSearchEvents() { + // Toggle search bar visibility + this.toggleButton.addEventListener('click', (e) => { + console.log('Search button clicked!'); + e.preventDefault(); + e.stopPropagation(); + this.showSearchBar(); + }); + + // Close search bar + this.closeButton.addEventListener('click', () => { + this.hideSearchBar(); + }); + + // Search on button click + this.searchButton.addEventListener('click', () => { + this.performSearch(); + }); + + // Search on Enter key + this.searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + if (this.suggestionsVisible && this.currentSuggestionIndex >= 0) { + this.selectSuggestion(this.currentSuggestionIndex); + } else { + this.performSearch(); + } + } + }); + + // Clear search (no clear button in inline mode, handled by close button) + + // Handle real-time suggestions + this.searchInput.addEventListener('input', (e) => { + const query = e.target.value.trim(); + + if (query.length > 0) { + this.debouncedSuggestionSearch(query); + } else { + this.hideSuggestions(); + this.showDefaultState(); + } + }); + + // Handle keyboard navigation for suggestions + this.searchInput.addEventListener('keydown', (e) => { + if (this.suggestionsVisible) { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.navigateSuggestions(1); + break; + case 'ArrowUp': + e.preventDefault(); + this.navigateSuggestions(-1); + break; + case 'Escape': + this.hideSuggestions(); + this.showDefaultState(); + break; + } + } + }); + + // Close sidepanel on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.searchVisible) { + this.hideSearchBar(); + } + }); + + // Close search bar when clicking outside (but not on map interactions) + document.addEventListener('click', (e) => { + if (this.searchVisible && + !e.target.closest('#location-search-container') && + !e.target.closest('#location-search-toggle') && + !e.target.closest('.leaflet-container')) { // Don't close on map interactions + this.hideSearchBar(); + } + }); + + // Maintain search bar position during map movements + this.map.on('movestart zoomstart', () => { + if (this.searchVisible) { + // Store current button position before map movement + this.storedButtonPosition = this.toggleButton.getBoundingClientRect(); + } + }); + + // Reposition search bar after map movements to maintain relative position + this.map.on('moveend zoomend', () => { + if (this.searchVisible && this.storedButtonPosition) { + // Recalculate position based on new button position + this.repositionSearchBar(); + } + }); + } + + async performSearch() { + const query = this.searchInput.value.trim(); + if (!query) return; + + this.currentSearchQuery = query; + this.showLoading(); + + try { + const response = await fetch(`/api/v1/locations?q=${encodeURIComponent(query)}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + this.displaySearchResults(data); + + } catch (error) { + console.error('Location search error:', error); + this.showError('Failed to search locations. Please try again.'); + } + } + + showLoading() { + // Hide other panels and show results with loading + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + this.resultsContainer.innerHTML = ` +
+
+
Searching for "${this.escapeHtml(this.currentSearchQuery)}"...
+
+ `; + } + + showError(message) { + // Hide other panels and show results with error + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + this.resultsContainer.innerHTML = ` +
+
⚠️
+
Search Failed
+
${this.escapeHtml(message)}
+
+ `; + } + + displaySearchResults(data) { + // Hide other panels and show results + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + if (!data.locations || data.locations.length === 0) { + this.resultsContainer.innerHTML = ` +
+
📍
+
No visits found
+
No visits found for "${this.escapeHtml(this.currentSearchQuery)}"
+
+ `; + return; + } + + this.searchResults = data.locations; + this.clearSearchMarkers(); + + let resultsHtml = ` +
+
Found ${data.total_locations} location(s)
+
for "${this.escapeHtml(this.currentSearchQuery)}"
+
+ `; + + data.locations.forEach((location, index) => { + resultsHtml += this.buildLocationResultHtml(location, index); + }); + + this.resultsContainer.innerHTML = resultsHtml; + + this.bindResultEvents(); + } + + buildLocationResultHtml(location, index) { + const firstVisit = location.visits[location.visits.length - 1]; + const lastVisit = location.visits[0]; + + // Group visits by year + const visitsByYear = this.groupVisitsByYear(location.visits); + + return ` +
+
+
${this.escapeHtml(location.place_name)}
+
${this.escapeHtml(location.address || '')}
+
+
${location.total_visits} visit(s)
+
+ first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)} +
+
+
+ + +
+ ${Object.entries(visitsByYear).map(([year, yearVisits]) => ` +
+
+ ${year} + ${yearVisits.length} visits + +
+ +
+ `).join('')} +
+
+ `; + } + + groupVisitsByYear(visits) { + const groups = {}; + visits.forEach(visit => { + const year = new Date(visit.date).getFullYear().toString(); + if (!groups[year]) { + groups[year] = []; + } + groups[year].push(visit); + }); + + // Sort years descending (most recent first) + const sortedGroups = {}; + Object.keys(groups) + .sort((a, b) => parseInt(b) - parseInt(a)) + .forEach(year => { + sortedGroups[year] = groups[year]; + }); + + return sortedGroups; + } + + formatDateShort(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + } + + bindResultEvents() { + // Bind click events to year toggles + const yearToggles = this.resultsContainer.querySelectorAll('.year-toggle'); + yearToggles.forEach(toggle => { + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + const locationIndex = parseInt(toggle.dataset.locationIndex); + const year = toggle.dataset.year; + this.toggleYear(locationIndex, year, toggle); + }); + }); + + // Bind click events to individual visits + const visitResults = this.resultsContainer.querySelectorAll('.visit-item'); + visitResults.forEach(visit => { + visit.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent triggering other clicks + const locationIndex = parseInt(visit.dataset.locationIndex); + const visitIndex = parseInt(visit.dataset.visitIndex); + this.focusOnVisit(this.searchResults[locationIndex], visitIndex); + }); + }); + } + + toggleYear(locationIndex, year, toggleElement) { + const yearVisitsContainer = document.getElementById(`year-${locationIndex}-${year}`); + const arrow = toggleElement.querySelector('.year-arrow'); + + if (yearVisitsContainer.classList.contains('hidden')) { + // Show visits + yearVisitsContainer.classList.remove('hidden'); + arrow.style.transform = 'rotate(90deg)'; + arrow.textContent = '▼'; + } else { + // Hide visits + yearVisitsContainer.classList.add('hidden'); + arrow.style.transform = 'rotate(0deg)'; + arrow.textContent = '▶'; + } + } + + + focusOnLocation(location) { + const [lat, lon] = location.coordinates; + this.map.setView([lat, lon], 16); + + // Flash the marker + const markers = this.searchMarkersLayer.getLayers(); + const targetMarker = markers.find(marker => { + const latLng = marker.getLatLng(); + return Math.abs(latLng.lat - lat) < 0.0001 && Math.abs(latLng.lng - lon) < 0.0001; + }); + + if (targetMarker) { + targetMarker.openPopup(); + } + + this.hideResults(); + } + + focusOnVisit(location, visitIndex) { + const visit = location.visits[visitIndex]; + if (!visit) return; + + // Navigate to the visit coordinates (more precise than location coordinates) + const [lat, lon] = visit.coordinates || location.coordinates; + this.map.setView([lat, lon], 18); // Higher zoom for individual visit + + // Parse the visit timestamp to create a time filter + const visitDate = new Date(visit.date); + const startTime = new Date(visitDate.getTime() - (2 * 60 * 60 * 1000)); // 2 hours before + const endTime = new Date(visitDate.getTime() + (2 * 60 * 60 * 1000)); // 2 hours after + + // Emit custom event for time filtering that other parts of the app can listen to + const timeFilterEvent = new CustomEvent('locationSearch:timeFilter', { + detail: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + visitDate: visit.date, + location: location.place_name, + coordinates: [lat, lon] + } + }); + + document.dispatchEvent(timeFilterEvent); + + // Create a special marker for the specific visit + this.addVisitMarker(lat, lon, visit, location); + + // DON'T hide results - keep sidebar open + // this.hideResults(); + } + + addVisitMarker(lat, lon, visit, location) { + // Remove existing visit marker if any + if (this.visitMarker) { + this.map.removeLayer(this.visitMarker); + } + + // Create a highlighted marker for the specific visit + this.visitMarker = L.circleMarker([lat, lon], { + radius: 12, + fillColor: '#22c55e', // Green color to distinguish from search results + color: '#ffffff', + weight: 3, + opacity: 1, + fillOpacity: 0.9 + }); + + const popupContent = ` +
+
${this.escapeHtml(location.place_name)}
+
${this.escapeHtml(location.address || '')}
+
+
Visit Details:
+
${this.formatDateTime(visit.date)}
+
Duration: ${visit.duration_estimate}
+
+
+ + +
+
+ `; + + this.visitMarker.bindPopup(popupContent, { + closeButton: true, + autoClose: false, // Don't auto-close when clicking elsewhere + closeOnEscapeKey: true, // Allow closing with Escape key + closeOnClick: false // Don't close when clicking on map + }); + + this.visitMarker.addTo(this.map); + this.visitMarker.openPopup(); + + // Add event listener to clean up when popup is closed + this.visitMarker.on('popupclose', () => { + if (this.visitMarker) { + this.map.removeLayer(this.visitMarker); + this.visitMarker = null; + } + }); + + // Store reference for manual cleanup if needed + this.currentVisitMarker = this.visitMarker; + } + + clearSearch() { + this.searchInput.value = ''; + this.hideResults(); + this.clearSearchMarkers(); + this.clearVisitMarker(); + this.currentSearchQuery = ''; + } + + clearVisitMarker() { + if (this.visitMarker) { + this.map.removeLayer(this.visitMarker); + this.visitMarker = null; + } + if (this.currentVisitMarker) { + this.map.removeLayer(this.currentVisitMarker); + this.currentVisitMarker = null; + } + + // Remove any visit notifications + const existingNotification = document.querySelector('.visit-navigation-notification'); + if (existingNotification) { + existingNotification.remove(); + } + } + + showSearchBar() { + console.log('showSearchBar called'); + + if (!this.searchBar) { + console.error('Search bar element not found!'); + return; + } + + // Position the search bar to the right of the search button at same height + const buttonRect = this.toggleButton.getBoundingClientRect(); + const mapRect = this.map.getContainer().getBoundingClientRect(); + + // Calculate position relative to the map container + const left = buttonRect.right - mapRect.left + 15; // 15px gap to the right of button + const top = buttonRect.top - mapRect.top; // Same height as button + + console.log('Positioning search bar at:', { left, top }); + + // Position search bar next to the button + this.searchBar.style.left = left + 'px'; + this.searchBar.style.top = top + 'px'; + this.searchBar.style.transform = 'none'; // Remove any transforms + this.searchBar.style.position = 'absolute'; // Position relative to map container + + // Show the search bar + this.searchBar.classList.remove('hidden'); + this.searchBar.style.setProperty('display', 'block', 'important'); + this.searchBar.style.visibility = 'visible'; + this.searchBar.style.opacity = '1'; + this.searchVisible = true; + + console.log('Search bar positioned next to button'); + + // Focus the search input for immediate typing + setTimeout(() => { + if (this.searchInput) { + this.searchInput.focus(); + } + }, 100); + } + + repositionSearchBar() { + if (!this.searchBar || !this.searchVisible) return; + + // Get current button position after map movement + const buttonRect = this.toggleButton.getBoundingClientRect(); + const mapRect = this.map.getContainer().getBoundingClientRect(); + + // Calculate new position + const left = buttonRect.right - mapRect.left + 15; + const top = buttonRect.top - mapRect.top; + + // Update search bar position + this.searchBar.style.left = left + 'px'; + this.searchBar.style.top = top + 'px'; + + console.log('Search bar repositioned after map movement'); + } + + hideSearchBar() { + this.searchBar.classList.add('hidden'); + this.searchBar.style.display = 'none'; + this.searchVisible = false; + this.clearSearch(); + this.hideResults(); + this.hideSuggestions(); + } + + showDefaultState() { + // No default panel in inline mode, just hide suggestions and results + this.hideSuggestions(); + this.hideResults(); + } + + clearSearchMarkers() { + // Note: No longer using search markers, but keeping method for compatibility + // Only clear visit markers if they exist + if (this.searchMarkersLayer) { + this.map.removeLayer(this.searchMarkersLayer); + this.searchMarkersLayer = null; + } + } + + hideResults() { + if (this.resultsPanel) { + this.resultsPanel.classList.add('hidden'); + } + } + + // Suggestion-related methods + debouncedSuggestionSearch(query) { + // Clear existing timeout + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + // Set new timeout for debounced search + this.searchTimeout = setTimeout(() => { + this.performSuggestionSearch(query); + }, 300); // 300ms debounce delay + } + + async performSuggestionSearch(query) { + if (query.length < 2) { + this.hideSuggestions(); + return; + } + + // Show loading state for suggestions + this.showSuggestionsLoading(); + + try { + const response = await fetch(`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Suggestions failed: ${response.status}`); + } + + const data = await response.json(); + this.displaySuggestions(data.suggestions || []); + + } catch (error) { + console.error('Suggestion search error:', error); + this.hideSuggestions(); + } + } + + showSuggestionsLoading() { + // Hide other panels and show suggestions with loading + this.resultsPanel.classList.add('hidden'); + this.suggestionsPanel.classList.remove('hidden'); + + this.suggestionsContainer.innerHTML = ` +
+
+
Finding suggestions...
+
+ `; + } + + displaySuggestions(suggestions) { + if (!suggestions.length) { + this.hideSuggestions(); + return; + } + + // Hide other panels and show suggestions + this.resultsPanel.classList.add('hidden'); + this.suggestionsPanel.classList.remove('hidden'); + + // Build suggestions HTML + let suggestionsHtml = ''; + suggestions.forEach((suggestion, index) => { + const isActive = index === this.currentSuggestionIndex; + suggestionsHtml += ` +
+
${this.escapeHtml(suggestion.name)}
+
${this.escapeHtml(suggestion.address || '')}
+
+ `; + }); + + this.suggestionsContainer.innerHTML = suggestionsHtml; + this.suggestionsVisible = true; + this.suggestions = suggestions; + + // Bind click events to suggestions + this.bindSuggestionEvents(); + } + + bindSuggestionEvents() { + const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); + suggestionItems.forEach(item => { + item.addEventListener('click', (e) => { + const index = parseInt(e.currentTarget.dataset.suggestionIndex); + this.selectSuggestion(index); + }); + }); + } + + navigateSuggestions(direction) { + if (!this.suggestions || !this.suggestions.length) return; + + const maxIndex = this.suggestions.length - 1; + + if (direction > 0) { + // Arrow down + this.currentSuggestionIndex = this.currentSuggestionIndex < maxIndex + ? this.currentSuggestionIndex + 1 + : 0; + } else { + // Arrow up + this.currentSuggestionIndex = this.currentSuggestionIndex > 0 + ? this.currentSuggestionIndex - 1 + : maxIndex; + } + + this.highlightActiveSuggestion(); + } + + highlightActiveSuggestion() { + const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); + + suggestionItems.forEach((item, index) => { + if (index === this.currentSuggestionIndex) { + item.classList.add('bg-blue-50', 'text-blue-700'); + item.classList.remove('bg-gray-50'); + } else { + item.classList.remove('bg-blue-50', 'text-blue-700'); + item.classList.add('bg-gray-50'); + } + }); + } + + selectSuggestion(index) { + if (!this.suggestions || index < 0 || index >= this.suggestions.length) return; + + const suggestion = this.suggestions[index]; + this.searchInput.value = suggestion.name; + this.hideSuggestions(); + this.showSearchLoading(suggestion.name); + this.performCoordinateSearch(suggestion); // Use coordinate-based search for selected suggestion + } + + showSearchLoading(locationName) { + // Hide other panels and show loading for search results + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + this.resultsContainer.innerHTML = ` +
+
+
Searching visits to
+
${this.escapeHtml(locationName)}
+
+ `; + } + + async performCoordinateSearch(suggestion) { + this.currentSearchQuery = suggestion.name; + // Loading state already shown by showSearchLoading + + try { + const params = new URLSearchParams({ + lat: suggestion.coordinates[0], + lon: suggestion.coordinates[1], + name: suggestion.name, + address: suggestion.address || '' + }); + + const response = await fetch(`/api/v1/locations?${params}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Coordinate search failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + this.displaySearchResults(data); + + } catch (error) { + console.error('Coordinate search error:', error); + this.showError('Failed to search locations. Please try again.'); + } + } + + hideSuggestions() { + this.suggestionsPanel.classList.add('hidden'); + this.suggestionsVisible = false; + this.currentSuggestionIndex = -1; + this.suggestions = []; + + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + this.searchTimeout = null; + } + } + + createVisitAt(lat, lon, placeName, visitDate, durationEstimate) { + console.log(`Creating visit at ${lat}, ${lon} for ${placeName} at ${visitDate} (duration: ${durationEstimate})`); + + // Close the current visit popup + if (this.visitMarker) { + this.visitMarker.closePopup(); + } + + // Calculate start and end times from the original visit + const { startTime, endTime } = this.calculateVisitTimes(visitDate, durationEstimate); + + this.showBasicVisitForm(lat, lon, placeName, startTime, endTime); + } + + showBasicVisitForm(lat, lon, placeName, presetStartTime, presetEndTime) { + // Close any existing visit form popups first + const existingPopups = document.querySelectorAll('.basic-visit-form-popup'); + existingPopups.forEach(popup => { + const leafletPopup = popup.closest('.leaflet-popup'); + if (leafletPopup) { + const closeButton = leafletPopup.querySelector('.leaflet-popup-close-button'); + if (closeButton) closeButton.click(); + } + }); + + // Use preset times if available, otherwise use current time defaults + let startTime, endTime; + + if (presetStartTime && presetEndTime) { + startTime = presetStartTime; + endTime = presetEndTime; + console.log('Using preset times:', { startTime, endTime }); + } else { + console.log('No preset times provided, using defaults'); + // Get current date/time for default values + const now = new Date(); + const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000)); + + // Format dates for datetime-local input + const formatDateTime = (date) => { + return date.toISOString().slice(0, 16); + }; + + startTime = formatDateTime(now); + endTime = formatDateTime(oneHourLater); + } + + // Create form HTML + const formHTML = ` +
+

Add New Visit

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+
+ `; + + // Create popup at the location + const basicVisitPopup = L.popup({ + closeOnClick: false, + autoClose: false, + maxWidth: 300, + className: 'basic-visit-form-popup' + }) + .setLatLng([lat, lon]) + .setContent(formHTML) + .openOn(this.map); + + // Add event listeners after the popup is added to DOM + setTimeout(() => { + const form = document.getElementById('basic-add-visit-form'); + const cancelButton = document.getElementById('basic-cancel-visit'); + const nameInput = document.getElementById('basic-visit-name'); + + if (form) { + form.addEventListener('submit', (e) => this.handleBasicFormSubmit(e, basicVisitPopup)); + } + + if (cancelButton) { + cancelButton.addEventListener('click', () => { + this.map.closePopup(basicVisitPopup); + }); + } + + // Focus and select the name input + if (nameInput) { + nameInput.focus(); + nameInput.select(); + } + }, 100); + } + + async handleBasicFormSubmit(event, popup) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + // Get form values + const visitData = { + visit: { + name: formData.get('name'), + started_at: formData.get('started_at'), + ended_at: formData.get('ended_at'), + latitude: formData.get('latitude'), + longitude: formData.get('longitude') + } + }; + + // Validate that end time is after start time + const startTime = new Date(visitData.visit.started_at); + const endTime = new Date(visitData.visit.ended_at); + + if (endTime <= startTime) { + alert('End time must be after start time'); + return; + } + + // Disable form while submitting + const submitButton = form.querySelector('button[type="submit"]'); + const originalText = submitButton.textContent; + submitButton.disabled = true; + submitButton.textContent = 'Creating...'; + + try { + const response = await fetch(`/api/v1/visits`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify(visitData) + }); + + const data = await response.json(); + + if (response.ok) { + alert(`Visit "${visitData.visit.name}" created successfully!`); + this.map.closePopup(popup); + + // Try to refresh visits layer if available + this.refreshVisitsIfAvailable(); + } else { + const errorMessage = data.error || data.message || 'Failed to create visit'; + alert(errorMessage); + } + } catch (error) { + console.error('Error creating visit:', error); + alert('Network error: Failed to create visit'); + } finally { + // Re-enable form + submitButton.disabled = false; + submitButton.textContent = originalText; + } + } + + refreshVisitsIfAvailable() { + // Try to refresh visits layer if available + const mapsController = document.querySelector('[data-controller*="maps"]'); + if (mapsController) { + const stimulusApp = window.Stimulus || window.stimulus; + if (stimulusApp) { + const controller = stimulusApp.getControllerForElementAndIdentifier(mapsController, 'maps'); + if (controller && controller.visitsManager && controller.visitsManager.fetchAndDisplayVisits) { + console.log('Refreshing visits layer after creating visit'); + controller.visitsManager.fetchAndDisplayVisits(); + } + } + } + } + + calculateVisitTimes(visitDate, durationEstimate) { + if (!visitDate) { + return { startTime: null, endTime: null }; + } + + try { + // Parse the visit date (e.g., "2022-12-27T18:01:00.000Z") + const visitDateTime = new Date(visitDate); + + // Parse duration estimate (e.g., "~15m", "~1h 44m", "~2h 30m") + let durationMinutes = 15; // Default to 15 minutes if parsing fails + + if (durationEstimate) { + const durationStr = durationEstimate.replace('~', '').trim(); + + // Match patterns like "15m", "1h 44m", "2h", etc. + const hoursMatch = durationStr.match(/(\d+)h/); + const minutesMatch = durationStr.match(/(\d+)m/); + + let hours = 0; + let minutes = 0; + + if (hoursMatch) { + hours = parseInt(hoursMatch[1]); + } + if (minutesMatch) { + minutes = parseInt(minutesMatch[1]); + } + + durationMinutes = (hours * 60) + minutes; + + // If no matches found, try to parse as pure minutes + if (durationMinutes === 0) { + const pureMinutes = parseInt(durationStr); + if (!isNaN(pureMinutes)) { + durationMinutes = pureMinutes; + } + } + } + + // Calculate start time (visit time) and end time (visit time + duration) + const startTime = visitDateTime.toISOString().slice(0, 16); // Format for datetime-local + const endDateTime = new Date(visitDateTime.getTime() + (durationMinutes * 60 * 1000)); + const endTime = endDateTime.toISOString().slice(0, 16); + + console.log(`Calculated visit times: ${startTime} to ${endTime} (duration: ${durationMinutes} minutes)`); + + return { startTime, endTime }; + } catch (error) { + console.error('Error calculating visit times:', error); + return { startTime: null, endTime: null }; + } + } + + // Utility methods + escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text ? text.replace(/[&<>"']/g, m => map[m]) : ''; + } + + formatDate(dateString) { + return new Date(dateString).toLocaleDateString(); + } + + formatDateTime(dateString) { + return new Date(dateString).toLocaleDateString() + ' ' + + new Date(dateString).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + } +} + +export { LocationSearch }; diff --git a/app/javascript/maps/visits.js b/app/javascript/maps/visits.js index f70cf765..4b907587 100644 --- a/app/javascript/maps/visits.js +++ b/app/javascript/maps/visits.js @@ -1572,7 +1572,7 @@ export class VisitsManager { // Show confirmation dialog const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.'); - + if (!confirmDelete) { return; } diff --git a/app/serializers/api/location_search_result_serializer.rb b/app/serializers/api/location_search_result_serializer.rb new file mode 100644 index 00000000..6cbc967e --- /dev/null +++ b/app/serializers/api/location_search_result_serializer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::LocationSearchResultSerializer + def initialize(search_result) + @search_result = search_result + end + + def call + { + query: @search_result[:query], + locations: serialize_locations(@search_result[:locations]), + total_locations: @search_result[:total_locations], + search_metadata: @search_result[:search_metadata] + } + end + + private + + def serialize_locations(locations) + locations.map do |location| + { + place_name: location[:place_name], + coordinates: location[:coordinates], + address: location[:address], + total_visits: location[:total_visits], + first_visit: location[:first_visit], + last_visit: location[:last_visit], + visits: serialize_visits(location[:visits]) + } + end + end + + def serialize_visits(visits) + visits.map do |visit| + { + timestamp: visit[:timestamp], + date: visit[:date], + coordinates: visit[:coordinates], + distance_meters: visit[:distance_meters], + duration_estimate: visit[:duration_estimate], + points_count: visit[:points_count], + accuracy_meters: visit[:accuracy_meters], + visit_details: visit[:visit_details] + } + end + end +end diff --git a/app/services/location_search/geocoding_service.rb b/app/services/location_search/geocoding_service.rb new file mode 100644 index 00000000..c8b42833 --- /dev/null +++ b/app/services/location_search/geocoding_service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module LocationSearch + class GeocodingService + MAX_RESULTS = 10 + + def initialize(query) + @query = query + end + + def search + return [] if query.blank? + + perform_geocoding_search(query) + rescue StandardError => e + Rails.logger.error "Geocoding search failed for query '#{query}': #{e.message}" + [] + end + + def provider_name + Geocoder.config.lookup.to_s.capitalize + end + + private + + attr_reader :query + + def perform_geocoding_search(query) + results = Geocoder.search(query, limit: MAX_RESULTS) + return [] if results.blank? + + normalize_geocoding_results(results) + end + + def normalize_geocoding_results(results) + normalized_results = results.filter_map do |result| + lat = result.latitude.to_f + lon = result.longitude.to_f + + next unless valid_coordinates?(lat, lon) + + { + lat: lat, + lon: lon, + name: result.address&.split(',')&.first || 'Unknown location', + address: result.address || '', + type: result.data&.dig('type') || result.data&.dig('class') || 'unknown', + provider_data: { + osm_id: result.data&.dig('osm_id'), + place_rank: result.data&.dig('place_rank'), + importance: result.data&.dig('importance') + } + } + end + + deduplicate_results(normalized_results) + end + + def deduplicate_results(results) + deduplicated = [] + + results.each do |result| + # Check if there's already a result within 100m + duplicate = deduplicated.find do |existing| + distance = calculate_distance_in_meters( + result[:lat], result[:lon], + existing[:lat], existing[:lon] + ) + distance < 100 # meters + end + + deduplicated << result unless duplicate + end + + deduplicated + end + + def calculate_distance_in_meters(lat1, lon1, lat2, lon2) + # Use Geocoder's distance calculation (same as in Distanceable concern) + distance_km = Geocoder::Calculations.distance_between( + [lat1, lon1], + [lat2, lon2], + units: :km + ) + + # Convert to meters and handle potential nil/invalid results + return 0 unless distance_km.is_a?(Numeric) && distance_km.finite? + + distance_km * 1000 # Convert km to meters + end + + def valid_coordinates?(lat, lon) + lat.between?(-90, 90) && lon.between?(-180, 180) + end + end +end diff --git a/app/services/location_search/point_finder.rb b/app/services/location_search/point_finder.rb new file mode 100644 index 00000000..63cb695d --- /dev/null +++ b/app/services/location_search/point_finder.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module LocationSearch + class PointFinder + def initialize(user, params = {}) + @user = user + @latitude = params[:latitude] + @longitude = params[:longitude] + @limit = params[:limit] || 50 + @date_from = params[:date_from] + @date_to = params[:date_to] + @radius_override = params[:radius_override] + end + + def call + return empty_result unless valid_coordinates? + + location = { + lat: @latitude, + lon: @longitude, + type: 'coordinate_search' + } + + find_matching_points([location]) + end + + private + + def find_matching_points(geocoded_locations) + results = [] + + geocoded_locations.each do |location| + search_radius = @radius_override || determine_search_radius(location[:type]) + + matching_points = spatial_matcher.find_points_near( + @user, + location[:lat], + location[:lon], + search_radius, + date_filter_options + ) + + if matching_points.empty? + wider_search = spatial_matcher.find_points_near( + @user, + location[:lat], + location[:lon], + 1000, # 1km radius for debugging + date_filter_options + ) + + next + end + + visits = result_aggregator.group_points_into_visits(matching_points) + + results << { + place_name: location[:name], + coordinates: [location[:lat], location[:lon]], + address: location[:address], + total_visits: visits.length, + first_visit: visits.first[:date], + last_visit: visits.last[:date], + visits: visits.take(@limit) + } + end + + { + locations: results, + total_locations: results.length, + search_metadata: {} + } + end + + def spatial_matcher + @spatial_matcher ||= LocationSearch::SpatialMatcher.new + end + + def result_aggregator + @result_aggregator ||= LocationSearch::ResultAggregator.new + end + + def date_filter_options + { + date_from: @date_from, + date_to: @date_to + } + end + + def determine_search_radius(location_type) + case location_type.to_s.downcase + when 'shop', 'store', 'retail' + 75 # Small radius for specific shops + when 'restaurant', 'cafe', 'food' + 75 # Small radius for specific restaurants + when 'building', 'house', 'address' + 50 # Very small radius for specific addresses + when 'street', 'road' + 50 # Very small radius for streets + when 'neighbourhood', 'neighborhood', 'district', 'suburb' + 300 # Medium radius for neighborhoods + when 'city', 'town', 'village' + 1000 # Large radius for cities + else + 500 # Default radius for unknown types + end + end + + def valid_coordinates? + @latitude.present? && @longitude.present? && + @latitude.to_f.between?(-90, 90) && @longitude.to_f.between?(-180, 180) + end + + def empty_result + { + locations: [], + total_locations: 0, + search_metadata: {} + } + end + end +end diff --git a/app/services/location_search/result_aggregator.rb b/app/services/location_search/result_aggregator.rb new file mode 100644 index 00000000..0c28000a --- /dev/null +++ b/app/services/location_search/result_aggregator.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module LocationSearch + class ResultAggregator + include ActionView::Helpers::TextHelper + + # Time threshold for grouping consecutive points into visits (minutes) + VISIT_TIME_THRESHOLD = 30 + + def group_points_into_visits(points) + return [] if points.empty? + + # Sort points by timestamp to handle unordered input + sorted_points = points.sort_by { |p| p[:timestamp] } + + visits = [] + current_visit_points = [] + + sorted_points.each do |point| + if current_visit_points.empty? || within_visit_threshold?(current_visit_points.last, point) + current_visit_points << point + else + # Finalize current visit and start a new one + visits << create_visit_from_points(current_visit_points) if current_visit_points.any? + current_visit_points = [point] + end + end + + # Don't forget the last visit + visits << create_visit_from_points(current_visit_points) if current_visit_points.any? + + visits.sort_by { |visit| -visit[:timestamp] } # Most recent first + end + + private + + def within_visit_threshold?(previous_point, current_point) + time_diff = (current_point[:timestamp] - previous_point[:timestamp]).abs / 60.0 # minutes + time_diff <= VISIT_TIME_THRESHOLD + end + + def create_visit_from_points(points) + return nil if points.empty? + + # Sort points by timestamp to get chronological order + sorted_points = points.sort_by { |p| p[:timestamp] } + first_point = sorted_points.first + last_point = sorted_points.last + + # Calculate visit duration + duration_minutes = if sorted_points.length > 1 + ((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round + else + # Single point visit - estimate based on typical stay time + 15 # minutes + end + + # Find the most accurate point (lowest accuracy value means higher precision) + most_accurate_point = points.min_by { |p| p[:accuracy] || 999999 } + + # Calculate average distance from search center + average_distance = (points.sum { |p| p[:distance_meters] } / points.length).round(2) + + { + timestamp: first_point[:timestamp], + date: first_point[:date], + coordinates: most_accurate_point[:coordinates], + distance_meters: average_distance, + duration_estimate: format_duration(duration_minutes), + points_count: points.length, + accuracy_meters: most_accurate_point[:accuracy], + visit_details: { + start_time: first_point[:date], + end_time: last_point[:date], + duration_minutes: duration_minutes, + city: most_accurate_point[:city], + country: most_accurate_point[:country], + altitude_range: calculate_altitude_range(points) + } + } + end + + def format_duration(minutes) + return "~#{pluralize(minutes, 'minute')}" if minutes < 60 + + hours = minutes / 60 + remaining_minutes = minutes % 60 + + if remaining_minutes == 0 + "~#{pluralize(hours, 'hour')}" + else + "~#{pluralize(hours, 'hour')} #{pluralize(remaining_minutes, 'minute')}" + end + end + + def calculate_altitude_range(points) + altitudes = points.map { |p| p[:altitude] }.compact + return nil if altitudes.empty? + + min_altitude = altitudes.min + max_altitude = altitudes.max + + if min_altitude == max_altitude + "#{min_altitude}m" + else + "#{min_altitude}m - #{max_altitude}m" + end + end + end +end diff --git a/app/services/location_search/spatial_matcher.rb b/app/services/location_search/spatial_matcher.rb new file mode 100644 index 00000000..0cbe62b2 --- /dev/null +++ b/app/services/location_search/spatial_matcher.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module LocationSearch + class SpatialMatcher + def initialize + # Using PostGIS for efficient spatial queries + end + + def find_points_near(user, latitude, longitude, radius_meters, date_options = {}) + points_query = build_spatial_query(user, latitude, longitude, radius_meters, date_options) + + ActiveRecord::Base.connection.exec_query(points_query) + .map { |row| format_point_result(row) } + .sort_by { |point| point[:timestamp] } + .reverse # Most recent first + end + + private + + def build_spatial_query(user, latitude, longitude, radius_meters, date_options = {}) + date_filter = build_date_filter(date_options) + + <<~SQL + SELECT + p.id, + p.timestamp, + ST_Y(p.lonlat::geometry) as latitude, + ST_X(p.lonlat::geometry) as longitude, + p.city, + p.country, + p.altitude, + p.accuracy, + ST_Distance(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography) as distance_meters, + TO_TIMESTAMP(p.timestamp) as recorded_at + FROM points p + WHERE p.user_id = #{user.id} + AND ST_DWithin(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography, #{radius_meters}) + #{date_filter} + ORDER BY p.timestamp DESC; + SQL + end + + def build_date_filter(date_options) + return '' unless date_options[:date_from] || date_options[:date_to] + + filters = [] + + if date_options[:date_from] + timestamp_from = date_options[:date_from].to_time.to_i + filters << "p.timestamp >= #{timestamp_from}" + end + + if date_options[:date_to] + # Add one day to include the entire end date + timestamp_to = (date_options[:date_to] + 1.day).to_time.to_i + filters << "p.timestamp < #{timestamp_to}" + end + + return '' if filters.empty? + + "AND #{filters.join(' AND ')}" + end + + def format_point_result(row) + { + id: row['id'].to_i, + timestamp: row['timestamp'].to_i, + coordinates: [row['latitude'].to_f, row['longitude'].to_f], + city: row['city'], + country: row['country'], + altitude: row['altitude']&.to_i, + accuracy: row['accuracy']&.to_i, + distance_meters: row['distance_meters'].to_f.round(2), + recorded_at: row['recorded_at'], + date: Time.zone.at(row['timestamp'].to_i).iso8601 + } + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 638e273f..1c3b2dda 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -100,6 +100,11 @@ Rails.application.routes.draw do get 'users/me', to: 'users#me' resources :areas, only: %i[index create update destroy] + resources :locations, only: %i[index] do + collection do + get 'suggestions' + end + end resources :points, only: %i[index create update destroy] resources :visits, only: %i[index create update destroy] do get 'possible_places', to: 'visits/possible_places#index', on: :member diff --git a/spec/factories/points.rb b/spec/factories/points.rb index acc097e9..779f18f0 100644 --- a/spec/factories/points.rb +++ b/spec/factories/points.rb @@ -28,7 +28,7 @@ FactoryBot.define do course { nil } course_accuracy { nil } external_track_id { nil } - lonlat { "POINT(#{FFaker::Geolocation.lng} #{FFaker::Geolocation.lat})" } + lonlat { "POINT(#{longitude} #{latitude})" } user country_id { nil } diff --git a/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb b/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb index 2bb19f2a..5fb3e701 100644 --- a/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb +++ b/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb @@ -7,10 +7,13 @@ RSpec.describe DataMigrations::MigratePointsLatlonJob, type: :job do it 'updates the lonlat column for all tracked points' do user = create(:user) point = create(:point, latitude: 2.0, longitude: 1.0, user: user) + + # Clear the lonlat to simulate points that need migration + point.update_column(:lonlat, nil) expect { subject.perform(user.id) }.to change { point.reload.lonlat - }.to(RGeo::Geographic.spherical_factory.point(1.0, 2.0)) + }.from(nil).to(RGeo::Geographic.spherical_factory.point(1.0, 2.0)) end end end diff --git a/spec/requests/api/v1/locations_spec.rb b/spec/requests/api/v1/locations_spec.rb new file mode 100644 index 00000000..23ab3719 --- /dev/null +++ b/spec/requests/api/v1/locations_spec.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::LocationsController, type: :request do + let(:user) { create(:user) } + let(:api_key) { user.api_key } + let(:headers) { { 'Authorization' => "Bearer #{api_key}" } } + + describe 'GET /api/v1/locations' do + context 'with valid authentication' do + context 'when coordinates are provided' do + let(:latitude) { 52.5200 } + let(:longitude) { 13.4050 } + let(:mock_search_result) do + { + query: nil, + locations: [ + { + place_name: 'Kaufland Mitte', + coordinates: [52.5200, 13.4050], + address: 'Alexanderplatz 1, Berlin', + total_visits: 2, + first_visit: '2024-01-15T09:30:00Z', + last_visit: '2024-03-20T18:45:00Z', + visits: [ + { + timestamp: 1711814700, + date: '2024-03-20T18:45:00Z', + coordinates: [52.5201, 13.4051], + distance_meters: 45.5, + duration_estimate: '~25m', + points_count: 8 + } + ] + } + ], + total_locations: 1, + search_metadata: { + geocoding_provider: 'photon', + candidates_found: 3, + search_time_ms: 234 + } + } + end + + before do + allow_any_instance_of(LocationSearch::PointFinder) + .to receive(:call).and_return(mock_search_result) + end + + it 'returns successful response with search results' do + get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['query']).to be_nil + expect(json_response['locations']).to be_an(Array) + expect(json_response['locations'].first['place_name']).to eq('Kaufland Mitte') + expect(json_response['total_locations']).to eq(1) + end + + it 'includes search metadata in response' do + get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers + + json_response = JSON.parse(response.body) + expect(json_response['search_metadata']).to include( + 'geocoding_provider' => 'photon', + 'candidates_found' => 3 + ) + end + + it 'passes search parameters to PointFinder service' do + expect(LocationSearch::PointFinder) + .to receive(:new) + .with(user, hash_including( + latitude: latitude, + longitude: longitude, + limit: 50, + date_from: nil, + date_to: nil, + radius_override: nil + )) + .and_return(double(call: mock_search_result)) + + get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers + end + + context 'with additional search parameters' do + let(:params) do + { + lat: latitude, + lon: longitude, + limit: 20, + date_from: '2024-01-01', + date_to: '2024-03-31', + radius_override: 200 + } + end + + it 'passes all parameters to the service' do + expect(LocationSearch::PointFinder) + .to receive(:new) + .with(user, hash_including( + latitude: latitude, + longitude: longitude, + limit: 20, + date_from: Date.parse('2024-01-01'), + date_to: Date.parse('2024-03-31'), + radius_override: 200 + )) + .and_return(double(call: mock_search_result)) + + get '/api/v1/locations', params: params, headers: headers + end + end + + context 'with invalid date parameters' do + it 'handles invalid date_from gracefully' do + expect { + get '/api/v1/locations', params: { lat: latitude, lon: longitude, date_from: 'invalid-date' }, headers: headers + }.not_to raise_error + + expect(response).to have_http_status(:ok) + end + + it 'handles invalid date_to gracefully' do + expect { + get '/api/v1/locations', params: { lat: latitude, lon: longitude, date_to: 'invalid-date' }, headers: headers + }.not_to raise_error + + expect(response).to have_http_status(:ok) + end + end + end + + context 'when no search results are found' do + let(:empty_result) do + { + query: 'NonexistentPlace', + locations: [], + total_locations: 0, + search_metadata: { geocoding_provider: nil, candidates_found: 0, search_time_ms: 0 } + } + end + + before do + allow_any_instance_of(LocationSearch::PointFinder) + .to receive(:call).and_return(empty_result) + end + + it 'returns empty results successfully' do + get '/api/v1/locations', params: { lat: 0.0, lon: 0.0 }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['locations']).to be_empty + expect(json_response['total_locations']).to eq(0) + end + end + + context 'when coordinates are missing' do + it 'returns bad request error' do + get '/api/v1/locations', headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Coordinates (lat, lon) are required') + end + end + + context 'when only latitude is provided' do + it 'returns bad request error' do + get '/api/v1/locations', params: { lat: 52.5200 }, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Coordinates (lat, lon) are required') + end + end + + context 'when coordinates are invalid' do + it 'returns bad request error for invalid latitude' do + get '/api/v1/locations', params: { lat: 91, lon: 0 }, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180') + end + + it 'returns bad request error for invalid longitude' do + get '/api/v1/locations', params: { lat: 0, lon: 181 }, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180') + end + end + + context 'when service raises an error' do + before do + allow_any_instance_of(LocationSearch::PointFinder) + .to receive(:call).and_raise(StandardError.new('Service error')) + end + + it 'returns internal server error' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: headers + + expect(response).to have_http_status(:internal_server_error) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Search failed. Please try again.') + end + end + end + + context 'without authentication' do + it 'returns unauthorized error' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with invalid API key' do + let(:invalid_headers) { { 'Authorization' => 'Bearer invalid_key' } } + + it 'returns unauthorized error' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: invalid_headers + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with user data isolation' do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:user1_headers) { { 'Authorization' => "Bearer #{user1.api_key}" } } + + before do + # Create points for both users + create(:point, user: user1, latitude: 52.5200, longitude: 13.4050) + create(:point, user: user2, latitude: 52.5200, longitude: 13.4050) + + # Mock service to verify user isolation + allow(LocationSearch::PointFinder).to receive(:new) do |user, _params| + expect(user).to eq(user1) # Should only be called with user1 + double(call: { query: nil, locations: [], total_locations: 0, search_metadata: {} }) + end + end + + it 'only searches within the authenticated user data' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: user1_headers + + expect(response).to have_http_status(:ok) + end + end + end + + describe 'GET /api/v1/locations/suggestions' do + context 'with valid authentication' do + let(:mock_suggestions) do + [ + { + lat: 52.5200, + lon: 13.4050, + name: 'Kaufland Mitte', + address: 'Alexanderplatz 1, Berlin', + type: 'shop' + }, + { + lat: 52.5100, + lon: 13.4000, + name: 'Kaufland Friedrichshain', + address: 'Warschauer Str. 80, Berlin', + type: 'shop' + } + ] + end + + before do + allow_any_instance_of(LocationSearch::GeocodingService) + .to receive(:search).and_return(mock_suggestions) + end + + context 'with valid search query' do + it 'returns formatted suggestions' do + get '/api/v1/locations/suggestions', params: { q: 'Kaufland' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_an(Array) + expect(json_response['suggestions'].length).to eq(2) + + first_suggestion = json_response['suggestions'].first + expect(first_suggestion).to include( + 'name' => 'Kaufland Mitte', + 'address' => 'Alexanderplatz 1, Berlin', + 'coordinates' => [52.5200, 13.4050], + 'type' => 'shop' + ) + end + + it 'limits suggestions to 10 results' do + large_suggestions = Array.new(10) do |i| + { + lat: 52.5000 + i * 0.001, + lon: 13.4000 + i * 0.001, + name: "Location #{i}", + address: "Address #{i}", + type: 'place' + } + end + + allow_any_instance_of(LocationSearch::GeocodingService) + .to receive(:search).and_return(large_suggestions) + + get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers + + json_response = JSON.parse(response.body) + expect(json_response['suggestions'].length).to eq(10) + end + end + + context 'with short search query' do + it 'returns empty suggestions for queries shorter than 2 characters' do + get '/api/v1/locations/suggestions', params: { q: 'a' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_empty + end + end + + context 'with blank query' do + it 'returns empty suggestions' do + get '/api/v1/locations/suggestions', params: { q: '' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_empty + end + end + + context 'when geocoding service raises an error' do + before do + allow_any_instance_of(LocationSearch::GeocodingService) + .to receive(:search).and_raise(StandardError.new('Geocoding error')) + end + + it 'returns empty suggestions gracefully' do + get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_empty + end + end + end + + context 'without authentication' do + it 'returns unauthorized error' do + get '/api/v1/locations/suggestions', params: { q: 'test' } + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/services/location_search/geocoding_service_spec.rb b/spec/services/location_search/geocoding_service_spec.rb new file mode 100644 index 00000000..ddbfa699 --- /dev/null +++ b/spec/services/location_search/geocoding_service_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::GeocodingService do + let(:query) { 'Kaufland Berlin' } + let(:service) { described_class.new(query) } + + describe '#search' do + context 'with valid query' do + let(:mock_geocoder_result) do + double( + 'Geocoder::Result', + latitude: 52.5200, + longitude: 13.4050, + address: 'Kaufland, Alexanderplatz 1, Berlin', + data: { + 'type' => 'shop', + 'osm_id' => '12345', + 'place_rank' => 30, + 'importance' => 0.8 + } + ) + end + + before do + allow(Geocoder).to receive(:search).and_return([mock_geocoder_result]) + allow(Geocoder.config).to receive(:lookup).and_return(:photon) + end + + it 'returns normalized geocoding results' do + results = service.search + + expect(results).to be_an(Array) + expect(results.first).to include( + lat: 52.5200, + lon: 13.4050, + name: 'Kaufland', + address: 'Kaufland, Alexanderplatz 1, Berlin', + type: 'shop' + ) + end + + it 'includes provider data' do + results = service.search + + expect(results.first[:provider_data]).to include( + osm_id: '12345', + place_rank: 30, + importance: 0.8 + ) + end + + it 'limits results to MAX_RESULTS' do + expect(Geocoder).to receive(:search).with(query, limit: 10) + + service.search + end + end + + context 'with blank query' do + let(:service) { described_class.new('') } + + it 'returns empty array' do + expect(service.search).to eq([]) + end + end + + context 'when Geocoder returns no results' do + before do + allow(Geocoder).to receive(:search).and_return([]) + end + + it 'returns empty array' do + expect(service.search).to eq([]) + end + end + + context 'when Geocoder raises an error' do + before do + allow(Geocoder).to receive(:search).and_raise(StandardError.new('Geocoding error')) + end + + it 'handles error gracefully and returns empty array' do + expect(service.search).to eq([]) + end + end + + context 'with invalid coordinates' do + let(:invalid_result) do + double( + 'Geocoder::Result', + latitude: 91.0, # Invalid latitude + longitude: 13.4050, + address: 'Invalid location', + data: {} + ) + end + + let(:valid_result) do + double( + 'Geocoder::Result', + latitude: 52.5200, + longitude: 13.4050, + address: 'Valid location', + data: {} + ) + end + + before do + allow(Geocoder).to receive(:search).and_return([invalid_result, valid_result]) + end + + it 'filters out results with invalid coordinates' do + results = service.search + + expect(results.length).to eq(1) + expect(results.first[:lat]).to eq(52.5200) + end + end + + describe '#deduplicate_results' do + let(:duplicate_results) do + [ + { + lat: 52.5200, + lon: 13.4050, + name: 'Location 1', + address: 'Address 1', + type: 'shop', + provider_data: {} + }, + { + lat: 52.5201, # Within 100m of first location + lon: 13.4051, + name: 'Location 2', + address: 'Address 2', + type: 'shop', + provider_data: {} + } + ] + end + + let(:mock_results) do + duplicate_results.map do |result| + double( + 'Geocoder::Result', + latitude: result[:lat], + longitude: result[:lon], + address: result[:address], + data: { 'type' => result[:type] } + ) + end + end + + before do + allow(Geocoder).to receive(:search).and_return(mock_results) + end + + it 'removes locations within 100m of each other' do + service = described_class.new('test') + results = service.search + + expect(results.length).to eq(1) + expect(results.first[:name]).to eq('Address 1') + end + end + end + + describe '#provider_name' do + before do + allow(Geocoder.config).to receive(:lookup).and_return(:nominatim) + end + + it 'returns the current geocoding provider name' do + expect(service.provider_name).to eq('Nominatim') + end + end +end \ No newline at end of file diff --git a/spec/services/location_search/point_finder_spec.rb b/spec/services/location_search/point_finder_spec.rb new file mode 100644 index 00000000..bd74f5da --- /dev/null +++ b/spec/services/location_search/point_finder_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::PointFinder do + let(:user) { create(:user) } + let(:service) { described_class.new(user, search_params) } + let(:search_params) { { latitude: 52.5200, longitude: 13.4050 } } + + describe '#call' do + context 'with valid coordinates' do + let(:mock_matching_points) do + [ + { + id: 1, + timestamp: 1711814700, + coordinates: [52.5201, 13.4051], + distance_meters: 45.5, + date: '2024-03-20T18:45:00Z' + } + ] + end + + let(:mock_visits) do + [ + { + timestamp: 1711814700, + date: '2024-03-20T18:45:00Z', + coordinates: [52.5201, 13.4051], + distance_meters: 45.5, + duration_estimate: '~25m', + points_count: 1 + } + ] + end + + before do + allow_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near).and_return(mock_matching_points) + + allow_any_instance_of(LocationSearch::ResultAggregator) + .to receive(:group_points_into_visits).and_return(mock_visits) + end + + it 'returns search results with location data' do + result = service.call + + expect(result[:locations]).to be_an(Array) + expect(result[:locations].first).to include( + coordinates: [52.5200, 13.4050], + total_visits: 1 + ) + end + + it 'calls spatial matcher with correct coordinates and radius' do + expect_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near) + .with(user, 52.5200, 13.4050, 500, { date_from: nil, date_to: nil }) + + service.call + end + + context 'with custom radius override' do + let(:search_params) { { latitude: 52.5200, longitude: 13.4050, radius_override: 150 } } + + it 'uses custom radius when override provided' do + expect_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near) + .with(user, anything, anything, 150, anything) + + service.call + end + end + + context 'with date filtering' do + let(:search_params) do + { + latitude: 52.5200, + longitude: 13.4050, + date_from: Date.parse('2024-01-01'), + date_to: Date.parse('2024-03-31') + } + end + + it 'passes date filters to spatial matcher' do + expect_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near) + .with(user, anything, anything, anything, { + date_from: Date.parse('2024-01-01'), + date_to: Date.parse('2024-03-31') + }) + + service.call + end + end + + context 'with limit parameter' do + let(:search_params) { { latitude: 52.5200, longitude: 13.4050, limit: 10 } } + let(:many_visits) { Array.new(15) { |i| { timestamp: i, date: "2024-01-#{i+1}T12:00:00Z" } } } + + before do + allow_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near).and_return([{}]) + + allow_any_instance_of(LocationSearch::ResultAggregator) + .to receive(:group_points_into_visits).and_return(many_visits) + end + + it 'limits the number of visits returned' do + result = service.call + + expect(result[:locations].first[:visits].length).to eq(10) + end + end + end + + context 'when no matching points found' do + let(:search_params) { { latitude: 52.5200, longitude: 13.4050 } } + + before do + allow_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near).and_return([]) + end + + it 'excludes locations with no visits' do + result = service.call + + expect(result[:locations]).to be_empty + expect(result[:total_locations]).to eq(0) + end + end + + context 'when coordinates are missing' do + let(:search_params) { {} } + + it 'returns empty result without calling services' do + expect(LocationSearch::SpatialMatcher).not_to receive(:new) + + result = service.call + + expect(result[:locations]).to be_empty + end + end + + context 'when only latitude is provided' do + let(:search_params) { { latitude: 52.5200 } } + + it 'returns empty result' do + result = service.call + + expect(result[:locations]).to be_empty + end + end + + context 'when only longitude is provided' do + let(:search_params) { { longitude: 13.4050 } } + + it 'returns empty result' do + result = service.call + + expect(result[:locations]).to be_empty + end + end + end +end diff --git a/spec/services/location_search/result_aggregator_spec.rb b/spec/services/location_search/result_aggregator_spec.rb new file mode 100644 index 00000000..99d85d24 --- /dev/null +++ b/spec/services/location_search/result_aggregator_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::ResultAggregator do + let(:service) { described_class.new } + + describe '#group_points_into_visits' do + context 'with empty points array' do + it 'returns empty array' do + result = service.group_points_into_visits([]) + expect(result).to eq([]) + end + end + + context 'with single point' do + let(:single_point) do + { + id: 1, + timestamp: 1711814700, + coordinates: [52.5200, 13.4050], + distance_meters: 45.5, + accuracy: 10, + date: '2024-03-20T18:45:00Z', + city: 'Berlin', + country: 'Germany', + altitude: 100 + } + end + + it 'creates a single visit' do + result = service.group_points_into_visits([single_point]) + + expect(result.length).to eq(1) + visit = result.first + expect(visit[:timestamp]).to eq(1711814700) + expect(visit[:coordinates]).to eq([52.5200, 13.4050]) + expect(visit[:points_count]).to eq(1) + end + + it 'estimates duration for single point visits' do + result = service.group_points_into_visits([single_point]) + + visit = result.first + expect(visit[:duration_estimate]).to eq('~15 minutes') + expect(visit[:visit_details][:duration_minutes]).to eq(15) + end + end + + context 'with consecutive points' do + let(:consecutive_points) do + [ + { + id: 1, + timestamp: 1711814700, # 18:45 + coordinates: [52.5200, 13.4050], + distance_meters: 45.5, + accuracy: 10, + date: '2024-03-20T18:45:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 2, + timestamp: 1711816500, # 19:15 (30 minutes later) + coordinates: [52.5201, 13.4051], + distance_meters: 48.2, + accuracy: 8, + date: '2024-03-20T19:15:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 3, + timestamp: 1711817400, # 19:30 (15 minutes later) + coordinates: [52.5199, 13.4049], + distance_meters: 42.1, + accuracy: 12, + date: '2024-03-20T19:30:00Z', + city: 'Berlin', + country: 'Germany' + } + ] + end + + it 'groups consecutive points into single visit' do + result = service.group_points_into_visits(consecutive_points) + + expect(result.length).to eq(1) + visit = result.first + expect(visit[:points_count]).to eq(3) + end + + it 'calculates visit duration from start to end' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + expect(visit[:duration_estimate]).to eq('~45 minutes') + expect(visit[:visit_details][:duration_minutes]).to eq(45) + end + + it 'uses most accurate point coordinates' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + # Point with accuracy 8 should be selected + expect(visit[:coordinates]).to eq([52.5201, 13.4051]) + expect(visit[:accuracy_meters]).to eq(8) + end + + it 'calculates average distance' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + expected_avg = (45.5 + 48.2 + 42.1) / 3 + expect(visit[:distance_meters]).to eq(expected_avg.round(2)) + end + + it 'sets correct start and end times' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z') + expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z') + end + end + + context 'with separate visits (time gaps)' do + let(:separate_visits_points) do + [ + { + id: 1, + timestamp: 1711814700, # 18:45 + coordinates: [52.5200, 13.4050], + distance_meters: 45.5, + accuracy: 10, + date: '2024-03-20T18:45:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 2, + timestamp: 1711816500, # 19:15 (30 minutes later - within threshold) + coordinates: [52.5201, 13.4051], + distance_meters: 48.2, + accuracy: 8, + date: '2024-03-20T19:15:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 3, + timestamp: 1711820100, # 20:15 (60 minutes after last point - exceeds threshold) + coordinates: [52.5199, 13.4049], + distance_meters: 42.1, + accuracy: 12, + date: '2024-03-20T20:15:00Z', + city: 'Berlin', + country: 'Germany' + } + ] + end + + it 'creates separate visits when time gap exceeds threshold' do + result = service.group_points_into_visits(separate_visits_points) + + expect(result.length).to eq(2) + expect(result.first[:points_count]).to eq(1) # Most recent visit (20:15) + expect(result.last[:points_count]).to eq(2) # Earlier visit (18:45-19:15) + end + + it 'orders visits by timestamp descending (most recent first)' do + result = service.group_points_into_visits(separate_visits_points) + + expect(result.first[:timestamp]).to be > result.last[:timestamp] + end + end + + context 'with duration formatting' do + let(:points_with_various_durations) do + # Helper to create points with time differences + base_time = 1711814700 + + [ + # Short visit (25 minutes) - 2 points 25 minutes apart + { id: 1, timestamp: base_time, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' }, + { id: 2, timestamp: base_time + 25 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:10:00Z' }, + + # Long visit (2 hours 15 minutes) - points every 15 minutes to stay within 30min threshold + { id: 3, timestamp: base_time + 70 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T19:55:00Z' }, + { id: 4, timestamp: base_time + 85 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:10:00Z' }, + { id: 5, timestamp: base_time + 100 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:25:00Z' }, + { id: 6, timestamp: base_time + 115 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:40:00Z' }, + { id: 7, timestamp: base_time + 130 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:55:00Z' }, + { id: 8, timestamp: base_time + 145 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:10:00Z' }, + { id: 9, timestamp: base_time + 160 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:25:00Z' }, + { id: 10, timestamp: base_time + 175 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:40:00Z' }, + { id: 11, timestamp: base_time + 190 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:55:00Z' }, + { id: 12, timestamp: base_time + 205 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T22:10:00Z' } + ] + end + + it 'formats duration correctly for minutes only' do + short_visit_points = points_with_various_durations.take(2) + result = service.group_points_into_visits(short_visit_points) + + expect(result.first[:duration_estimate]).to eq('~25 minutes') + end + + it 'formats duration correctly for hours and minutes' do + long_visit_points = points_with_various_durations.drop(2) + result = service.group_points_into_visits(long_visit_points) + + expect(result.first[:duration_estimate]).to eq('~2 hours 15 minutes') + end + + it 'formats duration correctly for hours only' do + # Create points within threshold but exactly 2 hours apart from first to last + exact_hour_points = [ + { id: 1, timestamp: 1711814700, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' }, + { id: 2, timestamp: 1711814700 + 25 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:10:00Z' }, + { id: 3, timestamp: 1711814700 + 50 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:35:00Z' }, + { id: 4, timestamp: 1711814700 + 75 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:00:00Z' }, + { id: 5, timestamp: 1711814700 + 100 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:25:00Z' }, + { id: 6, timestamp: 1711814700 + 120 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:45:00Z' } + ] + + result = service.group_points_into_visits(exact_hour_points) + + expect(result.first[:duration_estimate]).to eq('~2 hours') + end + end + + context 'with altitude data' do + let(:points_with_altitude) do + [ + { + id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050], + accuracy: 10, distance_meters: 50, altitude: 100, + date: '2024-03-20T18:45:00Z' + }, + { + id: 2, timestamp: 1711815600, coordinates: [52.5201, 13.4051], + accuracy: 10, distance_meters: 50, altitude: 105, + date: '2024-03-20T19:00:00Z' + }, + { + id: 3, timestamp: 1711816500, coordinates: [52.5199, 13.4049], + accuracy: 10, distance_meters: 50, altitude: 95, + date: '2024-03-20T19:15:00Z' + } + ] + end + + it 'includes altitude range in visit details' do + result = service.group_points_into_visits(points_with_altitude) + + visit = result.first + expect(visit[:visit_details][:altitude_range]).to eq('95m - 105m') + end + + context 'with same altitude for all points' do + before do + points_with_altitude.each { |p| p[:altitude] = 100 } + end + + it 'shows single altitude value' do + result = service.group_points_into_visits(points_with_altitude) + + visit = result.first + expect(visit[:visit_details][:altitude_range]).to eq('100m') + end + end + + context 'with missing altitude data' do + before do + points_with_altitude.each { |p| p.delete(:altitude) } + end + + it 'handles missing altitude gracefully' do + result = service.group_points_into_visits(points_with_altitude) + + visit = result.first + expect(visit[:visit_details][:altitude_range]).to be_nil + end + end + end + + context 'with unordered points' do + let(:unordered_points) do + [ + { id: 3, timestamp: 1711817400, coordinates: [52.5199, 13.4049], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:30:00Z' }, + { id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050], accuracy: 10, distance_meters: 50, date: '2024-03-20T18:45:00Z' }, + { id: 2, timestamp: 1711816500, coordinates: [52.5201, 13.4051], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:15:00Z' } + ] + end + + it 'handles unordered input correctly' do + result = service.group_points_into_visits(unordered_points) + + visit = result.first + expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z') + expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z') + end + end + end +end \ No newline at end of file diff --git a/spec/services/location_search/spatial_matcher_spec.rb b/spec/services/location_search/spatial_matcher_spec.rb new file mode 100644 index 00000000..a9550873 --- /dev/null +++ b/spec/services/location_search/spatial_matcher_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::SpatialMatcher do + let(:service) { described_class.new } + let(:user) { create(:user) } + let(:latitude) { 52.5200 } + let(:longitude) { 13.4050 } + let(:radius_meters) { 100 } + + describe '#find_points_near' do + let!(:near_point) do + create(:point, + user: user, + lonlat: "POINT(13.4051 52.5201)", + timestamp: 1.hour.ago.to_i, + city: 'Berlin', + country: 'Germany', + altitude: 100, + accuracy: 5 + ) + end + + let!(:far_point) do + create(:point, + user: user, + lonlat: "POINT(13.5000 52.6000)", + timestamp: 2.hours.ago.to_i + ) + end + + let!(:other_user_point) do + create(:point, + user: create(:user), + lonlat: "POINT(13.4051 52.5201)", + timestamp: 30.minutes.ago.to_i + ) + end + + context 'with points within radius' do + it 'returns points within the specified radius' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.length).to eq(1) + expect(results.first[:id]).to eq(near_point.id) + end + + it 'excludes points outside the radius' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).not_to include(far_point.id) + end + + it 'only includes points from the specified user' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).not_to include(other_user_point.id) + end + + it 'includes calculated distance' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.first[:distance_meters]).to be_a(Float) + expect(results.first[:distance_meters]).to be < radius_meters + end + + it 'includes point attributes' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + point = results.first + expect(point).to include( + id: near_point.id, + timestamp: near_point.timestamp, + coordinates: [52.5201, 13.4051], + city: 'Berlin', + country: 'Germany', + altitude: 100, + accuracy: 5 + ) + end + + it 'includes ISO8601 formatted date' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.first[:date]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + end + + it 'orders results by timestamp descending (most recent first)' do + # Create another nearby point with older timestamp + older_point = create(:point, + user: user, + lonlat: "POINT(13.4049 52.5199)", + timestamp: 3.hours.ago.to_i + ) + + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.first[:id]).to eq(near_point.id) # More recent + expect(results.last[:id]).to eq(older_point.id) # Older + end + end + + context 'with date filtering' do + let(:date_options) do + { + date_from: 2.days.ago.to_date, + date_to: Date.current + } + end + + let!(:old_point) do + create(:point, + user: user, + lonlat: "POINT(13.4051 52.5201)", + timestamp: 1.week.ago.to_i + ) + end + + it 'filters points by date range' do + results = service.find_points_near(user, latitude, longitude, radius_meters, date_options) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).to include(near_point.id) + expect(point_ids).not_to include(old_point.id) + end + + context 'with only date_from' do + let(:date_options) { { date_from: 2.hours.ago.to_date } } + + it 'includes points after date_from' do + results = service.find_points_near(user, latitude, longitude, radius_meters, date_options) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).to include(near_point.id) + end + end + + context 'with only date_to' do + let(:date_options) { { date_to: 2.days.ago.to_date } } + + it 'includes points before date_to' do + results = service.find_points_near(user, latitude, longitude, radius_meters, date_options) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).to include(old_point.id) + expect(point_ids).not_to include(near_point.id) + end + end + end + + context 'with no points within radius' do + it 'returns empty array' do + results = service.find_points_near(user, 60.0, 30.0, 100) # Far away coordinates + + expect(results).to be_empty + end + end + + context 'with edge cases' do + it 'handles points at the exact radius boundary' do + # This test would require creating a point at exactly 100m distance + # For simplicity, we'll test with a very small radius that should exclude our test point + results = service.find_points_near(user, latitude, longitude, 1) # 1 meter radius + + expect(results).to be_empty + end + + it 'handles negative coordinates' do + # Create point with negative coordinates + negative_point = create(:point, + user: user, + lonlat: "POINT(151.2093 -33.8688)", + timestamp: 1.hour.ago.to_i + ) + + results = service.find_points_near(user, -33.8688, 151.2093, 1000) + + expect(results.length).to eq(1) + expect(results.first[:id]).to eq(negative_point.id) + end + + it 'handles coordinates near poles' do + # Create point near north pole + polar_point = create(:point, + user: user, + lonlat: "POINT(0.0 89.0)", + timestamp: 1.hour.ago.to_i + ) + + results = service.find_points_near(user, 89.0, 0.0, 1000) + + expect(results.length).to eq(1) + expect(results.first[:id]).to eq(polar_point.id) + end + end + + context 'with large datasets' do + before do + # Create many points to test performance + 50.times do |i| + create(:point, + user: user, + lonlat: "POINT(#{longitude + (i * 0.0001)} #{latitude + (i * 0.0001)})", # Spread points slightly + timestamp: i.hours.ago.to_i + ) + end + end + + it 'efficiently queries large datasets' do + start_time = Time.current + + results = service.find_points_near(user, latitude, longitude, 1000) + + query_time = Time.current - start_time + expect(query_time).to be < 1.0 # Should complete within 1 second + expect(results.length).to be > 40 # Should find most of the points + end + end + end +end diff --git a/spec/services/points/raw_data_lonlat_extractor_spec.rb b/spec/services/points/raw_data_lonlat_extractor_spec.rb index 9fcb3800..f8f8d18d 100644 --- a/spec/services/points/raw_data_lonlat_extractor_spec.rb +++ b/spec/services/points/raw_data_lonlat_extractor_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -36,7 +36,7 @@ RSpec.describe Points::RawDataLonlatExtractor do 'latitudeE7' => 512_345_678 } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -55,7 +55,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -74,7 +74,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -92,7 +92,7 @@ RSpec.describe Points::RawDataLonlatExtractor do 'lat' => 51.2345678 } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -111,7 +111,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -129,7 +129,7 @@ RSpec.describe Points::RawDataLonlatExtractor do 'latitude' => 51.2345678 } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -148,7 +148,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } # Mock the entire call method since service doesn't have nil check before do