From c8242ce9021472992cf7115864bee638998f1916 Mon Sep 17 00:00:00 2001 From: Evgenii Burmakin Date: Sun, 14 Dec 2025 12:05:59 +0100 Subject: [PATCH] 0.36.3 (#2013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements * Pull only necessary data for map v2 points * Feature/raw data archive (#2009) * 0.36.2 (#2007) * fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements --------- Co-authored-by: Robin Tuszik * Remove esbuild scripts from package.json * Remove sideEffects field from package.json * Raw data archivation * Add tests * Fix tests * Fix tests * Update ExceptionReporter * Add schedule to run raw data archival job monthly * Change file structure for raw data archival feature * Update changelog and version for raw data archival feature --------- Co-authored-by: Robin Tuszik * Set raw_data to an empty hash instead of nil when archiving * Fix storage configuration and file extraction * Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018) * Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation * Remove raw data from visited cities api endpoint * Use user timezone to show dates on maps (#2020) * Fix/pre epoch time (#2019) * Use user timezone to show dates on maps * Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates. * Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates. * Fix tests failing due to new index on stats table * Fix failing specs * Update redis client configuration to support unix socket connection * Update changelog * Fix kml kmz import issues (#2023) * Fix kml kmz import issues * Refactor KML importer to improve readability and maintainability * Implement moving points in map v2 and fix route rendering logic to ma… (#2027) * Implement moving points in map v2 and fix route rendering logic to match map v1. * Fix route spec * fix(maplibre): update date format to ISO 8601 (#2029) * Add verification step to raw data archival process (#2028) * Add verification step to raw data archival process * Add actual verification of raw data archives after creation, and only clear raw_data for verified archives. * Fix failing specs * Eliminate zip-bomb risk * Fix potential memory leak in js * Return .keep files * Use Toast instead of alert for notifications * Add help section to navbar dropdown * Update changelog * Remove raw_data_archival_job * Ensure file is being closed properly after reading in Archivable concern --------- Co-authored-by: Robin Tuszik --- .app_version | 2 +- CHANGELOG.md | 18 + Gemfile | 3 +- Gemfile.lock | 4 + README.md | 2 - app/assets/builds/tailwind.css | 2 +- .../icons/lucide/outline/arrow-big-down.svg | 1 + .../outline/message-circle-question-mark.svg | 1 + .../v1/countries/visited_cities_controller.rb | 7 +- app/controllers/api/v1/points_controller.rb | 6 +- .../concerns/safe_timestamp_parser.rb | 24 + app/controllers/map/leaflet_controller.rb | 6 +- app/controllers/map/maplibre_controller.rb | 6 +- app/controllers/points_controller.rb | 6 +- .../controllers/family_members_controller.js | 12 +- .../controllers/maps/maplibre/data_loader.js | 15 +- .../maps/maplibre/event_handlers.js | 8 +- .../maps/maplibre/layer_manager.js | 4 +- .../maps/maplibre/routes_manager.js | 2 +- .../maps/maplibre/settings_manager.js | 20 +- .../controllers/maps/maplibre_controller.js | 5 +- app/javascript/controllers/maps_controller.js | 3 +- .../controllers/public_stat_map_controller.js | 12 +- .../maps_maplibre/layers/points_layer.js | 228 ++++++++++ .../maps_maplibre/layers/routes_layer.js | 8 +- .../maps_maplibre/services/api_client.js | 3 +- .../utils/geojson_transformers.js | 6 +- .../maps_maplibre/utils/settings_manager.js | 126 +++--- .../maps_maplibre/utils/speed_colors.js | 2 +- app/jobs/family/invitations/cleanup_job.rb | 2 + app/jobs/points/raw_data/archive_job.rb | 21 + .../points/raw_data/re_archive_month_job.rb | 19 + app/models/concerns/archivable.rb | 83 ++++ app/models/concerns/taggable.rb | 8 +- app/models/point.rb | 1 + app/models/points/raw_data_archive.rb | 40 ++ app/models/user.rb | 1 + app/services/exception_reporter.rb | 12 +- app/services/imports/source_detector.rb | 9 + app/services/kml/importer.rb | 340 +++++++++----- app/services/points/raw_data/archiver.rb | 184 ++++++++ .../points/raw_data/chunk_compressor.rb | 25 ++ app/services/points/raw_data/clearer.rb | 96 ++++ app/services/points/raw_data/restorer.rb | 105 +++++ app/services/points/raw_data/verifier.rb | 194 ++++++++ app/services/stats/calculate_month.rb | 3 +- app/services/users/import_data.rb | 38 +- app/views/map/leaflet/index.html.erb | 5 +- app/views/map/maplibre/index.html.erb | 5 +- app/views/shared/_navbar.html.erb | 62 ++- app/views/stats/public_month.html.erb | 3 +- app/views/trips/_form.html.erb | 2 +- app/views/trips/_path.html.erb | 2 +- app/views/trips/_trip.html.erb | 2 +- config/application.rb | 2 +- config/environments/production.rb | 2 +- config/initializers/01_constants.rb | 3 + config/initializers/03_dawarich_settings.rb | 4 + config/initializers/aws.rb | 7 +- config/initializers/sidekiq.rb | 2 +- config/sidekiq.yml | 1 + config/storage.yml | 6 +- ...6000001_create_points_raw_data_archives.rb | 23 + ...06000002_add_archival_columns_to_points.rb | 22 + ...06000004_validate_archival_foreign_keys.rb | 8 + ...1208210410_add_composite_index_to_stats.rb | 18 + ...verified_at_to_points_raw_data_archives.rb | 5 + db/schema.rb | 27 +- e2e/v2/map/layers/points.spec.js | 423 +++++++++++++++++- e2e/v2/map/layers/routes.spec.js | 12 +- lib/tasks/points_raw_data.rake | 295 ++++++++++++ lib/timestamps.rb | 17 +- .../concerns/safe_timestamp_parser_spec.rb | 101 +++++ spec/factories/points_raw_data_archives.rb | 32 ++ .../files/geojson/export_same_points.json | 2 +- .../files/kml/points_with_timestamps.kmz | Bin 0 -> 435 bytes spec/jobs/points/raw_data/archive_job_spec.rb | 46 ++ .../raw_data/re_archive_month_job_spec.rb | 34 ++ spec/models/concerns/archivable_spec.rb | 116 +++++ spec/models/points/raw_data_archive_spec.rb | 86 ++++ spec/models/user_spec.rb | 36 +- spec/requests/api/v1/stats_spec.rb | 49 +- spec/serializers/point_serializer_spec.rb | 4 +- spec/serializers/stats_serializer_spec.rb | 48 +- spec/services/kml/importer_spec.rb | 25 ++ .../services/points/raw_data/archiver_spec.rb | 202 +++++++++ .../points/raw_data/chunk_compressor_spec.rb | 94 ++++ spec/services/points/raw_data/clearer_spec.rb | 165 +++++++ .../services/points/raw_data/restorer_spec.rb | 228 ++++++++++ .../services/points/raw_data/verifier_spec.rb | 166 +++++++ spec/services/stats/calculate_month_spec.rb | 108 +++++ spec/swagger/api/v1/stats_controller_spec.rb | 4 +- 92 files changed, 3885 insertions(+), 342 deletions(-) create mode 100644 app/assets/svg/icons/lucide/outline/arrow-big-down.svg create mode 100644 app/assets/svg/icons/lucide/outline/message-circle-question-mark.svg create mode 100644 app/controllers/concerns/safe_timestamp_parser.rb create mode 100644 app/jobs/points/raw_data/archive_job.rb create mode 100644 app/jobs/points/raw_data/re_archive_month_job.rb create mode 100644 app/models/concerns/archivable.rb create mode 100644 app/models/points/raw_data_archive.rb create mode 100644 app/services/points/raw_data/archiver.rb create mode 100644 app/services/points/raw_data/chunk_compressor.rb create mode 100644 app/services/points/raw_data/clearer.rb create mode 100644 app/services/points/raw_data/restorer.rb create mode 100644 app/services/points/raw_data/verifier.rb create mode 100644 db/migrate/20251206000001_create_points_raw_data_archives.rb create mode 100644 db/migrate/20251206000002_add_archival_columns_to_points.rb create mode 100644 db/migrate/20251206000004_validate_archival_foreign_keys.rb create mode 100644 db/migrate/20251208210410_add_composite_index_to_stats.rb create mode 100644 db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb create mode 100644 lib/tasks/points_raw_data.rake create mode 100644 spec/controllers/concerns/safe_timestamp_parser_spec.rb create mode 100644 spec/factories/points_raw_data_archives.rb create mode 100644 spec/fixtures/files/kml/points_with_timestamps.kmz create mode 100644 spec/jobs/points/raw_data/archive_job_spec.rb create mode 100644 spec/jobs/points/raw_data/re_archive_month_job_spec.rb create mode 100644 spec/models/concerns/archivable_spec.rb create mode 100644 spec/models/points/raw_data_archive_spec.rb create mode 100644 spec/services/points/raw_data/archiver_spec.rb create mode 100644 spec/services/points/raw_data/chunk_compressor_spec.rb create mode 100644 spec/services/points/raw_data/clearer_spec.rb create mode 100644 spec/services/points/raw_data/restorer_spec.rb create mode 100644 spec/services/points/raw_data/verifier_spec.rb diff --git a/.app_version b/.app_version index 6d59e656..1d3b40ec 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.36.2 \ No newline at end of file +0.36.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 932373c4..95126129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [0.36.3] - 2025-12-14 + +## Added + +- Setting `ARCHIVE_RAW_DATA` env var to true will enable monthly raw data archiving for all users. It will look for points older than 2 months with `raw_data` column not empty and create a zip archive containing raw data files for each month. After successful archiving, raw data will be removed from the database to save space. Monthly archiving job is being run every day at 2:00 AM. Default env var value is false. +- In map v2, user can now move points when Points layer is enabled. #2024 +- In map v2, routes are now being rendered using same logic as in map v1, route-length-wise. #2026 + +## Fixed + +- Cities visited during a trip are now being calculated correctly. #547 #641 #1686 #1976 +- Points on the map are now show time in user's timezone. #580 #1035 #1682 +- Date range inputs now handle pre-epoch dates gracefully by clamping to valid PostgreSQL integer range. #685 +- Redis client now also being configured so that it could connect via unix socket. #1970 +- Importing KML files now creates points with correct timestamps. #1988 +- Importing KMZ files now works correctly. +- Map settings are now being respected in map v2. #2012 + # [0.36.2] - 2025-12-06 diff --git a/Gemfile b/Gemfile index de3aafef..3d1e1649 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,7 @@ gem 'bootsnap', require: false gem 'chartkick' gem 'data_migrate' gem 'devise' +gem 'foreman' gem 'geocoder', github: 'Freika/geocoder', branch: 'master' gem 'gpx' gem 'groupdate' @@ -55,7 +56,7 @@ gem 'stimulus-rails' gem 'tailwindcss-rails', '= 3.3.2' gem 'turbo-rails', '>= 2.0.17' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] -gem 'foreman' +gem 'with_advisory_lock' group :development, :test, :staging do gem 'brakeman', require: false diff --git a/Gemfile.lock b/Gemfile.lock index a32eb801..e558cc91 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -620,6 +620,9 @@ GEM base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) + with_advisory_lock (7.0.2) + activerecord (>= 7.2) + zeitwerk (>= 2.7) xpath (3.2.0) nokogiri (~> 1.8) zeitwerk (2.7.3) @@ -703,6 +706,7 @@ DEPENDENCIES turbo-rails (>= 2.0.17) tzinfo-data webmock + with_advisory_lock RUBY VERSION ruby 3.4.6p54 diff --git a/README.md b/README.md index e61d84ae..7257d484 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ [![Discord](https://dcbadge.limes.pink/api/server/pHsBjpt5J8)](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD) | [![Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dfreika%26type%3Dpatrons&style=for-the-badge)](https://www.patreon.com/freika) -[![CircleCI](https://circleci.com/gh/Freika/dawarich.svg?style=svg)](https://app.circleci.com/pipelines/github/Freika/dawarich) - --- ## 📸 Screenshots diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 806b34f7..9cc68eea 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -2,5 +2,5 @@ --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error{border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox-primary{--chkbg:var(--fallback-p,oklch(var(--p)/1));--chkfg:var(--fallback-pc,oklch(var(--pc)/1));--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.checkbox-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.checkbox-primary:checked,.checkbox-primary[aria-checked=true],.checkbox-primary[checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}details.collapse{width:100%}details.collapse summary{display:block;outline:2px solid transparent;outline-offset:2px;position:relative}details.collapse summary::-webkit-details-marker{display:none}.collapse:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse:has(.collapse-title:focus-visible),.collapse:has(>input[type=checkbox]:focus-visible),.collapse:has(>input[type=radio]:focus-visible){outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse-arrow>.collapse-title:after{--tw-translate-y:-100%;--tw-rotate:45deg;box-shadow:2px 2px;content:"";top:1.9rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform-origin:75% 75%;transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse-arrow>.collapse-title:after,.collapse-plus>.collapse-title:after{display:block;height:.5rem;inset-inline-end:1.4rem;pointer-events:none;position:absolute;transition-property:all;width:.5rem}.collapse-plus>.collapse-title:after{content:"+";top:.9rem;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse:not(.collapse-open):not(.collapse-close)>.collapse-title,.collapse:not(.collapse-open):not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-open):not(.collapse-close)>input[type=radio]:not(:checked){cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open])>.collapse-title{cursor:unset}.collapse-title{position:relative}:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){z-index:1}.collapse-title,:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){min-height:3.75rem;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;width:100%}.collapse-open>:where(.collapse-content),.collapse:focus:not(.collapse-close)>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input[type=checkbox]:checked~.collapse-content),.collapse:not(.collapse-close)>:where(input[type=radio]:checked~.collapse-content),.collapse[open]>:where(.collapse-content){padding-bottom:1rem;transition:padding .2s ease-out,background-color .2s ease-out}.collapse-arrow:focus:not(.collapse-close)>.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse-open.collapse-arrow>.collapse-title:after,.collapse[open].collapse-arrow>.collapse-title:after{--tw-translate-y:-50%;--tw-rotate:225deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.collapse-open.collapse-plus>.collapse-title:after,.collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse[open].collapse-plus>.collapse-title:after{content:"−"}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.\!input:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .\!input:before{aspect-ratio:1/1!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.\!modal::backdrop,.\!modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out!important;background-color:#0006!important}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\!modal:target .modal-box,.\!modal[open] .modal-box,.modal-toggle:checked+.\!modal .modal-box{--tw-translate-y:0px!important;--tw-scale-x:1!important;--tw-scale-y:1!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range-error{--range-shdw:var(--fallback-er,oklch(var(--er)/1))}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.steps .step-info+.step-info:before,.steps .step-info:after{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.steps .step-info:after{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.steps .step-success+.step-success:before,.steps .step-success:after{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.steps .step-success:after{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.steps .step-warning+.step-warning:before,.steps .step-warning:after{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.steps .step-warning:after{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.steps .step-error+.step-error:before,.steps .step-error:after{--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)))}.steps .step-error:after{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.toggle-error:focus-visible{outline-color:var(--fallback-er,oklch(var(--er)/1))}.toggle-error:checked,.toggle-error[aria-checked=true],.toggle-error[checked=true]{border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.btn-block{width:100%}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[type=checkbox].checkbox-xs{height:1rem;width:1rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}[type=radio].radio-sm{height:1.25rem;width:1.25rem}.range-sm{height:1.25rem}.range-sm::-webkit-slider-runnable-track{height:.25rem}.range-sm::-moz-range-track{height:.25rem}.range-sm::-webkit-slider-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.range-sm::-moz-range-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .toast:where(.toast-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[10000\]{z-index:10000}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.z-\[9999\]{z-index:9999}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-14{margin-left:3.5rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-full{max-height:100%}.min-h-80{min-height:20rem}.min-h-\[4rem\]{min-height:4rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-base-content\/20{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-primary\/10{background-color:var(--fallback-p,oklch(var(--p)/.1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity,1)))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/40{color:var(--fallback-bc,oklch(var(--bc)/.4))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-primary{--tw-ring-opacity:1;--tw-ring-color:var(--fallback-p,oklch(var(--p)/var(--tw-ring-opacity,1)))}.ring-offset-2{--tw-ring-offset-width:2px}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);cursor:default;height:auto;max-height:calc(100% - 20px);opacity:0;position:absolute;right:70px;top:10px;transform:scale(.95);transition:opacity .2s ease-in-out,transform .2s ease-in-out,visibility .2s;visibility:hidden;width:24rem;z-index:450}.leaflet-drawer *{cursor:default}.leaflet-drawer .btn,.leaflet-drawer a,.leaflet-drawer button,.leaflet-drawer input[type=checkbox]{cursor:pointer}.leaflet-drawer.open{opacity:1;transform:scale(1);visibility:visible}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{z-index:500}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{width:100%}em-emoji-picker{--color-border-over:rgba(0,0,0,.1);--color-border:rgba(0,0,0,.05);--font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--rgb-accent:96,165,250;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);max-width:400px;min-width:318px;overflow:auto;position:absolute;resize:horizontal;z-index:1000}[data-theme=dark] em-emoji-picker,html.dark em-emoji-picker{--color-border-over:hsla(0,0%,100%,.1);--color-border:hsla(0,0%,100%,.05);--rgb-accent:96,165,250}@media (max-width:768px){em-emoji-picker{max-width:90vw;min-width:280px}}.color-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:none;border-radius:.5rem}.color-input::-moz-color-swatch{border:none;border-radius:.5rem}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-error{border-color:transparent;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox-primary{--chkbg:var(--fallback-p,oklch(var(--p)/1));--chkfg:var(--fallback-pc,oklch(var(--pc)/1));--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.checkbox-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.checkbox-primary:checked,.checkbox-primary[aria-checked=true],.checkbox-primary[checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}details.collapse{width:100%}details.collapse summary{display:block;outline:2px solid transparent;outline-offset:2px;position:relative}details.collapse summary::-webkit-details-marker{display:none}.collapse:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse:has(.collapse-title:focus-visible),.collapse:has(>input[type=checkbox]:focus-visible),.collapse:has(>input[type=radio]:focus-visible){outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.collapse-arrow>.collapse-title:after{--tw-translate-y:-100%;--tw-rotate:45deg;box-shadow:2px 2px;content:"";top:1.9rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transform-origin:75% 75%;transition-duration:.15s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse-arrow>.collapse-title:after,.collapse-plus>.collapse-title:after{display:block;height:.5rem;inset-inline-end:1.4rem;pointer-events:none;position:absolute;transition-property:all;width:.5rem}.collapse-plus>.collapse-title:after{content:"+";top:.9rem;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.collapse:not(.collapse-open):not(.collapse-close)>.collapse-title,.collapse:not(.collapse-open):not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-open):not(.collapse-close)>input[type=radio]:not(:checked){cursor:pointer}.collapse:focus:not(.collapse-open):not(.collapse-close):not(.collapse[open])>.collapse-title{cursor:unset}.collapse-title{position:relative}:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){z-index:1}.collapse-title,:where(.collapse>input[type=checkbox]),:where(.collapse>input[type=radio]){min-height:3.75rem;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out;width:100%}.collapse-open>:where(.collapse-content),.collapse:focus:not(.collapse-close)>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input[type=checkbox]:checked~.collapse-content),.collapse:not(.collapse-close)>:where(input[type=radio]:checked~.collapse-content),.collapse[open]>:where(.collapse-content){padding-bottom:1rem;transition:padding .2s ease-out,background-color .2s ease-out}.collapse-arrow:focus:not(.collapse-close)>.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-arrow:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse-open.collapse-arrow>.collapse-title:after,.collapse[open].collapse-arrow>.collapse-title:after{--tw-translate-y:-50%;--tw-rotate:225deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.collapse-open.collapse-plus>.collapse-title:after,.collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=checkbox]:checked~.collapse-title:after,.collapse-plus:not(.collapse-close)>input[type=radio]:checked~.collapse-title:after,.collapse[open].collapse-plus>.collapse-title:after{content:"−"}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.\!input input{--tw-bg-opacity:1!important;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))!important;background-color:transparent!important}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.\!input input:focus{outline:2px solid transparent!important;outline-offset:2px!important}.input input:focus{outline:2px solid transparent;outline-offset:2px}.\!input[list]::-webkit-calendar-picker-indicator{line-height:1em!important}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.\!input:focus,.\!input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;box-shadow:none!important;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))!important;outline-offset:2px!important;outline-style:solid!important;outline-width:2px!important}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.\!input:disabled,.\!input[disabled]{cursor:not-allowed!important;--tw-border-opacity:1!important;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;color:var(--fallback-bc,oklch(var(--bc)/.4))!important}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.\!input:disabled::-moz-placeholder,.\!input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input:disabled::placeholder,.\!input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)))!important;--tw-placeholder-opacity:0.2!important}.\!input::-webkit-date-and-time-value{text-align:inherit!important}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .\!input{display:block!important;height:1.75rem!important;margin-left:auto!important;margin-right:auto!important;overflow:hidden!important;position:relative!important;text-overflow:ellipsis!important;white-space:nowrap!important;width:24rem!important;--tw-bg-opacity:1!important;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))!important;direction:ltr!important;padding-left:2rem!important}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .\!input:before{aspect-ratio:1/1!important;content:""!important;height:.75rem!important;left:.5rem!important;position:absolute!important;top:50%!important;--tw-translate-y:-50%!important;border-color:currentColor!important;border-radius:9999px!important;border-width:2px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;content:"";height:.75rem;left:.5rem;position:absolute;top:50%;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .\!input:after{content:""!important;height:.5rem!important;left:1.25rem!important;position:absolute!important;top:50%!important;--tw-translate-y:25%!important;--tw-rotate:-45deg!important;border-color:currentColor!important;border-radius:9999px!important;border-width:1px!important;opacity:.6!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.mockup-browser .mockup-browser-toolbar .input:after{content:"";height:.5rem;left:1.25rem;position:absolute;top:50%;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px;opacity:.6;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.\!modal::backdrop,.\!modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out!important;background-color:#0006!important}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.\!modal:target .modal-box,.\!modal[open] .modal-box,.modal-toggle:checked+.\!modal .modal-box{--tw-translate-y:0px!important;--tw-scale-x:1!important;--tw-scale-y:1!important;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))!important}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range-error{--range-shdw:var(--fallback-er,oklch(var(--er)/1))}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.steps .step-primary+.step-primary:before,.steps .step-primary:after{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.steps .step-accent+.step-accent:before,.steps .step-accent:after{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.steps .step-info+.step-info:before,.steps .step-info:after{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.steps .step-info:after{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.steps .step-success+.step-success:before,.steps .step-success:after{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.steps .step-success:after{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.steps .step-warning+.step-warning:before,.steps .step-warning:after{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.steps .step-warning:after{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.steps .step-error+.step-error:before,.steps .step-error:after{--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)))}.steps .step-error:after{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.textarea-bordered,.textarea:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.toggle-error:focus-visible{outline-color:var(--fallback-er,oklch(var(--er)/1))}.toggle-error:checked,.toggle-error[aria-checked=true],.toggle-error[checked=true]{border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-er,oklch(var(--er)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.btn-block{width:100%}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[type=checkbox].checkbox-xs{height:1rem;width:1rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}[type=radio].radio-sm{height:1.25rem;width:1.25rem}.range-sm{height:1.25rem}.range-sm::-webkit-slider-runnable-track{height:.25rem}.range-sm::-moz-range-track{height:.25rem}.range-sm::-webkit-slider-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.range-sm::-moz-range-thumb{height:1.25rem;width:1.25rem;--filler-offset:0.5rem}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .toast:where(.toast-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.tooltip-left:before{left:auto;right:var(--tooltip-offset)}.tooltip-left:before,.tooltip-right:before{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:before{left:var(--tooltip-offset);right:auto}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.tooltip-left:after{border-color:transparent transparent transparent var(--tooltip-color);left:auto;right:calc(var(--tooltip-tail-offset) + .0625rem)}.tooltip-left:after,.tooltip-right:after{bottom:auto;top:50%;transform:translateY(-50%)}.tooltip-right:after{border-color:transparent var(--tooltip-color) transparent transparent;left:calc(var(--tooltip-tail-offset) + .0625rem);right:auto}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.bottom-0{bottom:0}.left-0{left:0}.left-2{left:.5rem}.left-4{left:1rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-5{right:1.25rem}.top-0{top:0}.top-16{top:4rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[10000\]{z-index:10000}.z-\[1\]{z-index:1}.z-\[6000\]{z-index:6000}.z-\[9999\]{z-index:9999}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-14{margin-left:3.5rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-screen{height:100vh}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.max-h-full{max-height:100%}.min-h-80{min-height:20rem}.min-h-\[4rem\]{min-height:4rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-base-content\/20{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-primary\/10{background-color:var(--fallback-p,oklch(var(--p)/.1))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity,1)))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/40{color:var(--fallback-bc,oklch(var(--bc)/.4))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-primary{--tw-ring-opacity:1;--tw-ring-color:var(--fallback-p,oklch(var(--p)/var(--tw-ring-opacity,1)))}.ring-offset-2{--tw-ring-offset-width:2px}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);cursor:default;height:auto;max-height:calc(100% - 20px);opacity:0;position:absolute;right:70px;top:10px;transform:scale(.95);transition:opacity .2s ease-in-out,transform .2s ease-in-out,visibility .2s;visibility:hidden;width:24rem;z-index:450}.leaflet-drawer *{cursor:default}.leaflet-drawer .btn,.leaflet-drawer a,.leaflet-drawer button,.leaflet-drawer input[type=checkbox]{cursor:pointer}.leaflet-drawer.open{opacity:1;transform:scale(1);visibility:visible}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{z-index:500}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{width:100%}em-emoji-picker{--color-border-over:rgba(0,0,0,.1);--color-border:rgba(0,0,0,.05);--font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--rgb-accent:96,165,250;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15);max-width:400px;min-width:318px;overflow:auto;position:absolute;resize:horizontal;z-index:1000}[data-theme=dark] em-emoji-picker,html.dark em-emoji-picker{--color-border-over:hsla(0,0%,100%,.1);--color-border:hsla(0,0%,100%,.05);--rgb-accent:96,165,250}@media (max-width:768px){em-emoji-picker{max-width:90vw;min-width:280px}}.color-input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:none;padding:0}.color-input::-webkit-color-swatch-wrapper{padding:0}.color-input::-webkit-color-swatch{border:none;border-radius:.5rem}.color-input::-moz-color-swatch{border:none;border-radius:.5rem}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact .timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:scale-105:hover,.hover\:scale-110:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@media (min-width:640px){.sm\:inline{display:inline}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}}@media (min-width:768px){.md\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:\!block{display:block!important}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:items-end{align-items:flex-end}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}.lg\:text-left{text-align:left}} \ No newline at end of file diff --git a/app/assets/svg/icons/lucide/outline/arrow-big-down.svg b/app/assets/svg/icons/lucide/outline/arrow-big-down.svg new file mode 100644 index 00000000..462a595f --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/arrow-big-down.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/message-circle-question-mark.svg b/app/assets/svg/icons/lucide/outline/message-circle-question-mark.svg new file mode 100644 index 00000000..8950f7b7 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/message-circle-question-mark.svg @@ -0,0 +1 @@ + diff --git a/app/controllers/api/v1/countries/visited_cities_controller.rb b/app/controllers/api/v1/countries/visited_cities_controller.rb index 5af80348..5efee0d6 100644 --- a/app/controllers/api/v1/countries/visited_cities_controller.rb +++ b/app/controllers/api/v1/countries/visited_cities_controller.rb @@ -1,14 +1,17 @@ # frozen_string_literal: true class Api::V1::Countries::VisitedCitiesController < ApiController + include SafeTimestampParser + before_action :validate_params def index - start_at = DateTime.parse(params[:start_at]).to_i - end_at = DateTime.parse(params[:end_at]).to_i + start_at = safe_timestamp(params[:start_at]) + end_at = safe_timestamp(params[:end_at]) points = current_api_user .points + .without_raw_data .where(timestamp: start_at..end_at) render json: { data: CountriesAndCities.new(points).call } diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index ad5dca57..1595d326 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class Api::V1::PointsController < ApiController + include SafeTimestampParser + before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy] before_action :validate_points_limit, only: %i[create] def index - start_at = params[:start_at]&.to_datetime&.to_i - end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i + start_at = params[:start_at].present? ? safe_timestamp(params[:start_at]) : nil + end_at = params[:end_at].present? ? safe_timestamp(params[:end_at]) : Time.zone.now.to_i order = params[:order] || 'desc' points = current_api_user diff --git a/app/controllers/concerns/safe_timestamp_parser.rb b/app/controllers/concerns/safe_timestamp_parser.rb new file mode 100644 index 00000000..b2e833dc --- /dev/null +++ b/app/controllers/concerns/safe_timestamp_parser.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module SafeTimestampParser + extend ActiveSupport::Concern + + private + + def safe_timestamp(date_string) + return Time.zone.now.to_i if date_string.blank? + + parsed_time = Time.zone.parse(date_string) + + # Time.zone.parse returns epoch time (2000-01-01) for unparseable strings + # Check if it's a valid parse by seeing if year is suspiciously at epoch + return Time.zone.now.to_i if parsed_time.nil? || (parsed_time.year == 2000 && !date_string.include?('2000')) + + min_timestamp = Time.zone.parse('1970-01-01').to_i + max_timestamp = Time.zone.parse('2100-01-01').to_i + + parsed_time.to_i.clamp(min_timestamp, max_timestamp) + rescue ArgumentError, TypeError + Time.zone.now.to_i + end +end diff --git a/app/controllers/map/leaflet_controller.rb b/app/controllers/map/leaflet_controller.rb index 2c5e2672..660b9615 100644 --- a/app/controllers/map/leaflet_controller.rb +++ b/app/controllers/map/leaflet_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Map::LeafletController < ApplicationController + include SafeTimestampParser + before_action :authenticate_user! layout 'map', only: :index @@ -71,14 +73,14 @@ class Map::LeafletController < ApplicationController end def start_at - return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present? + return safe_timestamp(params[:start_at]) if params[:start_at].present? return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any? Time.zone.today.beginning_of_day.to_i end def end_at - return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present? + return safe_timestamp(params[:end_at]) if params[:end_at].present? return Time.zone.at(points.last.timestamp).end_of_day.to_i if points.any? Time.zone.today.end_of_day.to_i diff --git a/app/controllers/map/maplibre_controller.rb b/app/controllers/map/maplibre_controller.rb index 529242d5..b11bc1e8 100644 --- a/app/controllers/map/maplibre_controller.rb +++ b/app/controllers/map/maplibre_controller.rb @@ -1,5 +1,7 @@ module Map class MaplibreController < ApplicationController + include SafeTimestampParser + before_action :authenticate_user! layout 'map' @@ -11,13 +13,13 @@ module Map private def start_at - return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present? + return safe_timestamp(params[:start_at]) if params[:start_at].present? Time.zone.today.beginning_of_day.to_i end def end_at - return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present? + return safe_timestamp(params[:end_at]) if params[:end_at].present? Time.zone.today.end_of_day.to_i end diff --git a/app/controllers/points_controller.rb b/app/controllers/points_controller.rb index 65d99698..87cdd1a4 100644 --- a/app/controllers/points_controller.rb +++ b/app/controllers/points_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PointsController < ApplicationController + include SafeTimestampParser + before_action :authenticate_user! def index @@ -40,13 +42,13 @@ class PointsController < ApplicationController def start_at return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil? - Time.zone.parse(params[:start_at]).to_i + safe_timestamp(params[:start_at]) end def end_at return Time.zone.today.end_of_day.to_i if params[:end_at].nil? - Time.zone.parse(params[:end_at]).to_i + safe_timestamp(params[:end_at]) end def points diff --git a/app/javascript/controllers/family_members_controller.js b/app/javascript/controllers/family_members_controller.js index 9440d536..36a3db52 100644 --- a/app/javascript/controllers/family_members_controller.js +++ b/app/javascript/controllers/family_members_controller.js @@ -7,7 +7,8 @@ export default class extends Controller { static values = { features: Object, - userTheme: String + userTheme: String, + timezone: String } connect() { @@ -106,7 +107,8 @@ export default class extends Controller { }); // Format timestamp for display - const lastSeen = new Date(location.updated_at).toLocaleString(); + const timezone = this.timezoneValue || 'UTC'; + const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone }); // Create small tooltip that shows automatically const tooltipContent = this.createTooltipContent(lastSeen, location.battery); @@ -176,7 +178,8 @@ export default class extends Controller { existingMarker.setIcon(newIcon); // Update tooltip content - const lastSeen = new Date(locationData.updated_at).toLocaleString(); + const timezone = this.timezoneValue || 'UTC'; + const lastSeen = new Date(locationData.updated_at).toLocaleString('en-US', { timeZone: timezone }); const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery); existingMarker.setTooltipContent(tooltipContent); @@ -214,7 +217,8 @@ export default class extends Controller { }) }); - const lastSeen = new Date(location.updated_at).toLocaleString(); + const timezone = this.timezoneValue || 'UTC'; + const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone }); const tooltipContent = this.createTooltipContent(lastSeen, location.battery); familyMarker.bindTooltip(tooltipContent, { diff --git a/app/javascript/controllers/maps/maplibre/data_loader.js b/app/javascript/controllers/maps/maplibre/data_loader.js index 8b583cc0..165702e8 100644 --- a/app/javascript/controllers/maps/maplibre/data_loader.js +++ b/app/javascript/controllers/maps/maplibre/data_loader.js @@ -7,9 +7,17 @@ import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor' * Handles loading and transforming data from API */ export class DataLoader { - constructor(api, apiKey) { + constructor(api, apiKey, settings = {}) { this.api = api this.apiKey = apiKey + this.settings = settings + } + + /** + * Update settings (called when user changes settings) + */ + updateSettings(settings) { + this.settings = settings } /** @@ -30,7 +38,10 @@ export class DataLoader { // Transform points to GeoJSON performanceMonitor.mark('transform-geojson') data.pointsGeoJSON = pointsToGeoJSON(data.points) - data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points) + data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, { + distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000, + timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60 + }) performanceMonitor.measure('transform-geojson') // Fetch visits diff --git a/app/javascript/controllers/maps/maplibre/event_handlers.js b/app/javascript/controllers/maps/maplibre/event_handlers.js index 812635d6..be214d13 100644 --- a/app/javascript/controllers/maps/maplibre/event_handlers.js +++ b/app/javascript/controllers/maps/maplibre/event_handlers.js @@ -18,7 +18,7 @@ export class EventHandlers { const content = `
-
Time: ${formatTimestamp(properties.timestamp)}
+
Time: ${formatTimestamp(properties.timestamp, this.controller.timezoneValue)}
${properties.battery ? `
Battery: ${properties.battery}%
` : ''} ${properties.altitude ? `
Altitude: ${Math.round(properties.altitude)}m
` : ''} ${properties.velocity ? `
Speed: ${Math.round(properties.velocity)} km/h
` : ''} @@ -35,8 +35,8 @@ export class EventHandlers { const feature = e.features[0] const properties = feature.properties - const startTime = formatTimestamp(properties.started_at) - const endTime = formatTimestamp(properties.ended_at) + const startTime = formatTimestamp(properties.started_at, this.controller.timezoneValue) + const endTime = formatTimestamp(properties.ended_at, this.controller.timezoneValue) const durationHours = Math.round(properties.duration / 3600) const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m` @@ -70,7 +70,7 @@ export class EventHandlers { const content = `
${properties.photo_url ? `Photo` : ''} - ${properties.taken_at ? `
Taken: ${formatTimestamp(properties.taken_at)}
` : ''} + ${properties.taken_at ? `
Taken: ${formatTimestamp(properties.taken_at, this.controller.timezoneValue)}
` : ''}
` diff --git a/app/javascript/controllers/maps/maplibre/layer_manager.js b/app/javascript/controllers/maps/maplibre/layer_manager.js index 36105e95..b31fd539 100644 --- a/app/javascript/controllers/maps/maplibre/layer_manager.js +++ b/app/javascript/controllers/maps/maplibre/layer_manager.js @@ -247,7 +247,9 @@ export class LayerManager { _addPointsLayer(pointsGeoJSON) { if (!this.layers.pointsLayer) { this.layers.pointsLayer = new PointsLayer(this.map, { - visible: this.settings.pointsVisible !== false // Default true unless explicitly false + visible: this.settings.pointsVisible !== false, // Default true unless explicitly false + apiClient: this.api, + layerManager: this }) this.layers.pointsLayer.add(pointsGeoJSON) } else { diff --git a/app/javascript/controllers/maps/maplibre/routes_manager.js b/app/javascript/controllers/maps/maplibre/routes_manager.js index bf9fc76c..a0d4ad31 100644 --- a/app/javascript/controllers/maps/maplibre/routes_manager.js +++ b/app/javascript/controllers/maps/maplibre/routes_manager.js @@ -173,7 +173,7 @@ export class RoutesManager { timestamp: f.properties.timestamp })) || [] - const distanceThresholdMeters = this.settings.metersBetweenRoutes || 500 + const distanceThresholdMeters = this.settings.metersBetweenRoutes || 1000 const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60 const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors') diff --git a/app/javascript/controllers/maps/maplibre/settings_manager.js b/app/javascript/controllers/maps/maplibre/settings_manager.js index d3b70b27..4c8c848d 100644 --- a/app/javascript/controllers/maps/maplibre/settings_manager.js +++ b/app/javascript/controllers/maps/maplibre/settings_manager.js @@ -22,12 +22,17 @@ export class SettingsController { } /** - * Load settings (sync from backend and localStorage) + * Load settings (sync from backend) */ async loadSettings() { this.settings = await SettingsManager.sync() this.controller.settings = this.settings - console.log('[Maps V2] Settings loaded:', this.settings) + + // Update dataLoader with new settings + if (this.controller.dataLoader) { + this.controller.dataLoader.updateSettings(this.settings) + } + return this.settings } @@ -134,8 +139,6 @@ export class SettingsController { if (speedColoredRoutesToggle) { speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false } - - console.log('[Maps V2] UI controls synced with settings') } /** @@ -154,7 +157,6 @@ export class SettingsController { // Reload layers after style change this.map.once('style.load', () => { - console.log('Style loaded, reloading map data') this.controller.loadMapData() }) } @@ -203,11 +205,17 @@ export class SettingsController { // Apply settings to current map await this.applySettingsToMap(settings) - // Save to backend and localStorage + // Save to backend for (const [key, value] of Object.entries(settings)) { await SettingsManager.updateSetting(key, value) } + // Update controller settings and dataLoader + this.controller.settings = { ...this.controller.settings, ...settings } + if (this.controller.dataLoader) { + this.controller.dataLoader.updateSettings(this.controller.settings) + } + Toast.success('Settings updated successfully') } diff --git a/app/javascript/controllers/maps/maplibre_controller.js b/app/javascript/controllers/maps/maplibre_controller.js index 5e416623..c9e5e1d6 100644 --- a/app/javascript/controllers/maps/maplibre_controller.js +++ b/app/javascript/controllers/maps/maplibre_controller.js @@ -26,7 +26,8 @@ export default class extends Controller { static values = { apiKey: String, startDate: String, - endDate: String + endDate: String, + timezone: String } static targets = [ @@ -92,7 +93,7 @@ export default class extends Controller { // Initialize managers this.layerManager = new LayerManager(this.map, this.settings, this.api) - this.dataLoader = new DataLoader(this.api, this.apiKeyValue) + this.dataLoader = new DataLoader(this.api, this.apiKeyValue, this.settings) this.eventHandlers = new EventHandlers(this.map, this) this.filterManager = new FilterManager(this.dataLoader) this.mapDataManager = new MapDataManager(this) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index bf8b9653..8491ba8f 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -2220,6 +2220,7 @@ export default class extends BaseController { return; } + const timezone = this.timezone || 'UTC'; const html = citiesData.map(country => `

${country.country}

@@ -2228,7 +2229,7 @@ export default class extends BaseController {
  • ${city.city} - (${new Date(city.timestamp * 1000).toLocaleDateString()}) + (${new Date(city.timestamp * 1000).toLocaleDateString('en-US', { timeZone: timezone })})
  • `).join('')} diff --git a/app/javascript/controllers/public_stat_map_controller.js b/app/javascript/controllers/public_stat_map_controller.js index c218fd0c..3170d0e8 100644 --- a/app/javascript/controllers/public_stat_map_controller.js +++ b/app/javascript/controllers/public_stat_map_controller.js @@ -10,7 +10,8 @@ export default class extends BaseController { uuid: String, dataBounds: Object, hexagonsAvailable: Boolean, - selfHosted: String + selfHosted: String, + timezone: String }; connect() { @@ -247,10 +248,11 @@ export default class extends BaseController { } buildPopupContent(props) { - const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A'; - const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A'; - const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString() : ''; - const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString() : ''; + const timezone = this.timezoneValue || 'UTC'; + const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A'; + const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A'; + const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : ''; + const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : ''; return `
    diff --git a/app/javascript/maps_maplibre/layers/points_layer.js b/app/javascript/maps_maplibre/layers/points_layer.js index 8a7f9d33..114dfc42 100644 --- a/app/javascript/maps_maplibre/layers/points_layer.js +++ b/app/javascript/maps_maplibre/layers/points_layer.js @@ -1,11 +1,25 @@ import { BaseLayer } from './base_layer' +import { Toast } from 'maps_maplibre/components/toast' /** * Points layer for displaying individual location points + * Supports dragging points to update their positions */ export class PointsLayer extends BaseLayer { constructor(map, options = {}) { super(map, { id: 'points', ...options }) + this.apiClient = options.apiClient + this.layerManager = options.layerManager + this.isDragging = false + this.draggedFeature = null + this.canvas = null + + // Bind event handlers once and store references for proper cleanup + this._onMouseEnter = this.onMouseEnter.bind(this) + this._onMouseLeave = this.onMouseLeave.bind(this) + this._onMouseDown = this.onMouseDown.bind(this) + this._onMouseMove = this.onMouseMove.bind(this) + this._onMouseUp = this.onMouseUp.bind(this) } getSourceConfig() { @@ -34,4 +48,218 @@ export class PointsLayer extends BaseLayer { } ] } + + /** + * Enable dragging for points + */ + enableDragging() { + if (this.draggingEnabled) return + + this.draggingEnabled = true + this.canvas = this.map.getCanvasContainer() + + // Change cursor to pointer when hovering over points + this.map.on('mouseenter', this.id, this._onMouseEnter) + this.map.on('mouseleave', this.id, this._onMouseLeave) + + // Handle drag events + this.map.on('mousedown', this.id, this._onMouseDown) + } + + /** + * Disable dragging for points + */ + disableDragging() { + if (!this.draggingEnabled) return + + this.draggingEnabled = false + + this.map.off('mouseenter', this.id, this._onMouseEnter) + this.map.off('mouseleave', this.id, this._onMouseLeave) + this.map.off('mousedown', this.id, this._onMouseDown) + } + + onMouseEnter() { + this.canvas.style.cursor = 'move' + } + + onMouseLeave() { + if (!this.isDragging) { + this.canvas.style.cursor = '' + } + } + + onMouseDown(e) { + // Prevent default map drag behavior + e.preventDefault() + + // Store the feature being dragged + this.draggedFeature = e.features[0] + this.isDragging = true + this.canvas.style.cursor = 'grabbing' + + // Bind mouse move and up events + this.map.on('mousemove', this._onMouseMove) + this.map.once('mouseup', this._onMouseUp) + } + + onMouseMove(e) { + if (!this.isDragging || !this.draggedFeature) return + + // Get the new coordinates + const coords = e.lngLat + + // Update the feature's coordinates in the source + const source = this.map.getSource(this.sourceId) + if (source) { + const data = source._data + const feature = data.features.find(f => f.properties.id === this.draggedFeature.properties.id) + if (feature) { + feature.geometry.coordinates = [coords.lng, coords.lat] + source.setData(data) + } + } + } + + async onMouseUp(e) { + if (!this.isDragging || !this.draggedFeature) return + + const coords = e.lngLat + const pointId = this.draggedFeature.properties.id + const originalCoords = this.draggedFeature.geometry.coordinates + + // Clean up drag state + this.isDragging = false + this.canvas.style.cursor = '' + this.map.off('mousemove', this._onMouseMove) + + // Update the point on the backend + try { + await this.updatePointPosition(pointId, coords.lat, coords.lng) + + // Update routes after successful point update + await this.updateConnectedRoutes(pointId, originalCoords, [coords.lng, coords.lat]) + } catch (error) { + console.error('Failed to update point:', error) + // Revert the point position on error + const source = this.map.getSource(this.sourceId) + if (source) { + const data = source._data + const feature = data.features.find(f => f.properties.id === pointId) + if (feature && originalCoords) { + feature.geometry.coordinates = originalCoords + source.setData(data) + } + } + Toast.error('Failed to update point position. Please try again.') + } + + this.draggedFeature = null + } + + /** + * Update point position via API + */ + async updatePointPosition(pointId, latitude, longitude) { + if (!this.apiClient) { + throw new Error('API client not configured') + } + + const response = await fetch(`/api/v1/points/${pointId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.apiClient.apiKey}` + }, + body: JSON.stringify({ + point: { + latitude: latitude.toString(), + longitude: longitude.toString() + } + }) + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return response.json() + } + + /** + * Update connected route segments when a point is moved + */ + async updateConnectedRoutes(pointId, oldCoords, newCoords) { + if (!this.layerManager) { + console.warn('LayerManager not configured, cannot update routes') + return + } + + const routesLayer = this.layerManager.getLayer('routes') + if (!routesLayer) { + console.warn('Routes layer not found') + return + } + + const routesSource = this.map.getSource(routesLayer.sourceId) + if (!routesSource) { + console.warn('Routes source not found') + return + } + + const routesData = routesSource._data + if (!routesData || !routesData.features) { + return + } + + // Tolerance for coordinate comparison (account for floating point precision) + const tolerance = 0.0001 + let routesUpdated = false + + // Find and update route segments that contain the moved point + routesData.features.forEach(feature => { + if (feature.geometry.type === 'LineString') { + const coordinates = feature.geometry.coordinates + + // Check each coordinate in the line + for (let i = 0; i < coordinates.length; i++) { + const coord = coordinates[i] + + // Check if this coordinate matches the old position + if (Math.abs(coord[0] - oldCoords[0]) < tolerance && + Math.abs(coord[1] - oldCoords[1]) < tolerance) { + // Update to new position + coordinates[i] = newCoords + routesUpdated = true + } + } + } + }) + + // Update the routes source if any routes were modified + if (routesUpdated) { + routesSource.setData(routesData) + } + } + + /** + * Override add method to enable dragging when layer is added + */ + add(data) { + super.add(data) + + // Wait for next tick to ensure layers are fully added before enabling dragging + setTimeout(() => { + this.enableDragging() + }, 100) + } + + /** + * Override remove method to clean up dragging handlers + */ + remove() { + this.disableDragging() + super.remove() + } } diff --git a/app/javascript/maps_maplibre/layers/routes_layer.js b/app/javascript/maps_maplibre/layers/routes_layer.js index 56009ed1..0539114e 100644 --- a/app/javascript/maps_maplibre/layers/routes_layer.js +++ b/app/javascript/maps_maplibre/layers/routes_layer.js @@ -31,7 +31,13 @@ export class RoutesLayer extends BaseLayer { 'line-cap': 'round' }, paint: { - 'line-color': '#f97316', // Solid orange color + // Use color from feature properties if available, otherwise default blue + 'line-color': [ + 'case', + ['has', 'color'], + ['get', 'color'], + '#0000ff' // Default blue color (matching v1) + ], 'line-width': 3, 'line-opacity': 0.8 } diff --git a/app/javascript/maps_maplibre/services/api_client.js b/app/javascript/maps_maplibre/services/api_client.js index 661f5f0e..1ef9e871 100644 --- a/app/javascript/maps_maplibre/services/api_client.js +++ b/app/javascript/maps_maplibre/services/api_client.js @@ -18,7 +18,8 @@ export class ApiClient { start_at, end_at, page: page.toString(), - per_page: per_page.toString() + per_page: per_page.toString(), + slim: 'true' }) const response = await fetch(`${this.baseURL}/points?${params}`, { diff --git a/app/javascript/maps_maplibre/utils/geojson_transformers.js b/app/javascript/maps_maplibre/utils/geojson_transformers.js index 9cfe30e6..72a1fd11 100644 --- a/app/javascript/maps_maplibre/utils/geojson_transformers.js +++ b/app/javascript/maps_maplibre/utils/geojson_transformers.js @@ -28,9 +28,10 @@ export function pointsToGeoJSON(points) { /** * Format timestamp for display * @param {number|string} timestamp - Unix timestamp (seconds) or ISO 8601 string + * @param {string} timezone - IANA timezone string (e.g., 'Europe/Berlin') * @returns {string} Formatted date/time */ -export function formatTimestamp(timestamp) { +export function formatTimestamp(timestamp, timezone = 'UTC') { // Handle different timestamp formats let date if (typeof timestamp === 'string') { @@ -49,6 +50,7 @@ export function formatTimestamp(timestamp) { month: 'short', day: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', + timeZone: timezone }) } diff --git a/app/javascript/maps_maplibre/utils/settings_manager.js b/app/javascript/maps_maplibre/utils/settings_manager.js index 8e5cbf42..a5058e27 100644 --- a/app/javascript/maps_maplibre/utils/settings_manager.js +++ b/app/javascript/maps_maplibre/utils/settings_manager.js @@ -1,21 +1,20 @@ /** * Settings manager for persisting user preferences - * Supports both localStorage (fallback) and backend API (primary) + * Loads settings from backend API only (no localStorage) */ -const STORAGE_KEY = 'dawarich-maps-maplibre-settings' - const DEFAULT_SETTINGS = { mapStyle: 'light', enabledMapLayers: ['Points', 'Routes'], // Compatible with v1 map - // Advanced settings - routeOpacity: 1.0, - fogOfWarRadius: 1000, + // Advanced settings (matching v1 naming) + routeOpacity: 0.6, + fogOfWarRadius: 100, fogOfWarThreshold: 1, - metersBetweenRoutes: 500, + metersBetweenRoutes: 1000, minutesBetweenRoutes: 60, pointsRenderingMode: 'raw', - speedColoredRoutes: false + speedColoredRoutes: false, + speedColorScale: '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300' } // Mapping between v2 layer names and v1 layer names in enabled_map_layers array @@ -34,7 +33,15 @@ const LAYER_NAME_MAP = { // Mapping between frontend settings and backend API keys const BACKEND_SETTINGS_MAP = { mapStyle: 'maps_maplibre_style', - enabledMapLayers: 'enabled_map_layers' + enabledMapLayers: 'enabled_map_layers', + routeOpacity: 'route_opacity', + fogOfWarRadius: 'fog_of_war_meters', + fogOfWarThreshold: 'fog_of_war_threshold', + metersBetweenRoutes: 'meters_between_routes', + minutesBetweenRoutes: 'minutes_between_routes', + pointsRenderingMode: 'points_rendering_mode', + speedColoredRoutes: 'speed_colored_routes', + speedColorScale: 'speed_color_scale' } export class SettingsManager { @@ -51,9 +58,8 @@ export class SettingsManager { } /** - * Get all settings (localStorage first, then merge with defaults) + * Get all settings from cache or defaults * Converts enabled_map_layers array to individual boolean flags - * Uses cached settings if available to avoid race conditions * @returns {Object} Settings object */ static getSettings() { @@ -62,21 +68,11 @@ export class SettingsManager { return { ...this.cachedSettings } } - try { - const stored = localStorage.getItem(STORAGE_KEY) - const settings = stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS + // Convert enabled_map_layers array to individual boolean flags + const expandedSettings = this._expandLayerSettings(DEFAULT_SETTINGS) + this.cachedSettings = expandedSettings - // Convert enabled_map_layers array to individual boolean flags - const expandedSettings = this._expandLayerSettings(settings) - - // Cache the settings - this.cachedSettings = expandedSettings - - return { ...expandedSettings } - } catch (error) { - console.error('Failed to load settings:', error) - return DEFAULT_SETTINGS - } + return { ...expandedSettings } } /** @@ -141,14 +137,31 @@ export class SettingsManager { const frontendSettings = {} Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => { if (backendKey in backendSettings) { - frontendSettings[frontendKey] = backendSettings[backendKey] + let value = backendSettings[backendKey] + + // Convert backend values to correct types + if (frontendKey === 'routeOpacity') { + value = parseFloat(value) || DEFAULT_SETTINGS.routeOpacity + } else if (frontendKey === 'fogOfWarRadius') { + value = parseInt(value) || DEFAULT_SETTINGS.fogOfWarRadius + } else if (frontendKey === 'fogOfWarThreshold') { + value = parseInt(value) || DEFAULT_SETTINGS.fogOfWarThreshold + } else if (frontendKey === 'metersBetweenRoutes') { + value = parseInt(value) || DEFAULT_SETTINGS.metersBetweenRoutes + } else if (frontendKey === 'minutesBetweenRoutes') { + value = parseInt(value) || DEFAULT_SETTINGS.minutesBetweenRoutes + } else if (frontendKey === 'speedColoredRoutes') { + value = value === true || value === 'true' + } + + frontendSettings[frontendKey] = value } }) - // Merge with defaults, but prioritize backend's enabled_map_layers completely + // Merge with defaults const mergedSettings = { ...DEFAULT_SETTINGS, ...frontendSettings } - // If backend has enabled_map_layers, use it as-is (don't merge with defaults) + // If backend has enabled_map_layers, use it as-is if (backendSettings.enabled_map_layers) { mergedSettings.enabledMapLayers = backendSettings.enabled_map_layers } @@ -156,8 +169,8 @@ export class SettingsManager { // Convert enabled_map_layers array to individual boolean flags const expandedSettings = this._expandLayerSettings(mergedSettings) - // Save to localStorage and cache - this.saveToLocalStorage(expandedSettings) + // Cache the settings + this.cachedSettings = expandedSettings return expandedSettings } catch (error) { @@ -167,18 +180,11 @@ export class SettingsManager { } /** - * Save all settings to localStorage and update cache + * Update cache with new settings * @param {Object} settings - Settings object */ - static saveToLocalStorage(settings) { - try { - // Update cache first - this.cachedSettings = { ...settings } - // Then save to localStorage - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) - } catch (error) { - console.error('Failed to save settings to localStorage:', error) - } + static updateCache(settings) { + this.cachedSettings = { ...settings } } /** @@ -203,7 +209,19 @@ export class SettingsManager { // Use the collapsed array backendSettings[backendKey] = enabledMapLayers } else if (frontendKey in settings) { - backendSettings[backendKey] = settings[frontendKey] + let value = settings[frontendKey] + + // Convert frontend values to backend format + if (frontendKey === 'routeOpacity') { + value = parseFloat(value).toString() + } else if (frontendKey === 'fogOfWarRadius' || frontendKey === 'fogOfWarThreshold' || + frontendKey === 'metersBetweenRoutes' || frontendKey === 'minutesBetweenRoutes') { + value = parseInt(value).toString() + } else if (frontendKey === 'speedColoredRoutes') { + value = Boolean(value) + } + + backendSettings[backendKey] = value } }) @@ -220,7 +238,6 @@ export class SettingsManager { throw new Error(`Failed to save settings: ${response.status}`) } - console.log('[Settings] Saved to backend successfully:', backendSettings) return true } catch (error) { console.error('[Settings] Failed to save to backend:', error) @@ -238,7 +255,7 @@ export class SettingsManager { } /** - * Update a specific setting (saves to both localStorage and backend) + * Update a specific setting and save to backend * @param {string} key - Setting key * @param {*} value - New value */ @@ -253,28 +270,23 @@ export class SettingsManager { settings.enabledMapLayers = this._collapseLayerSettings(settings) } - // Save to localStorage immediately - this.saveToLocalStorage(settings) + // Update cache immediately + this.updateCache(settings) - // Save to backend (non-blocking) - this.saveToBackend(settings).catch(error => { - console.warn('[Settings] Backend save failed, but localStorage updated:', error) - }) + // Save to backend + await this.saveToBackend(settings) } /** * Reset to defaults */ - static resetToDefaults() { + static async resetToDefaults() { try { - localStorage.removeItem(STORAGE_KEY) this.cachedSettings = null // Clear cache - // Also reset on backend + // Reset on backend if (this.apiKey) { - this.saveToBackend(DEFAULT_SETTINGS).catch(error => { - console.warn('[Settings] Failed to reset backend settings:', error) - }) + await this.saveToBackend(DEFAULT_SETTINGS) } } catch (error) { console.error('Failed to reset settings:', error) @@ -282,9 +294,9 @@ export class SettingsManager { } /** - * Sync settings: load from backend and merge with localStorage + * Sync settings: load from backend * Call this on app initialization - * @returns {Promise} Merged settings + * @returns {Promise} Settings from backend */ static async sync() { const backendSettings = await this.loadFromBackend() diff --git a/app/javascript/maps_maplibre/utils/speed_colors.js b/app/javascript/maps_maplibre/utils/speed_colors.js index 112ac1d0..d83f20b1 100644 --- a/app/javascript/maps_maplibre/utils/speed_colors.js +++ b/app/javascript/maps_maplibre/utils/speed_colors.js @@ -102,7 +102,7 @@ function haversineDistance(lat1, lon1, lat2, lon2) { */ export function getSpeedColor(speedKmh, useSpeedColors, speedColorScale) { if (!useSpeedColors) { - return '#f97316' // Default orange color + return '#0000ff' // Default blue color (matching v1) } let colorStops diff --git a/app/jobs/family/invitations/cleanup_job.rb b/app/jobs/family/invitations/cleanup_job.rb index a80ad443..7100938a 100644 --- a/app/jobs/family/invitations/cleanup_job.rb +++ b/app/jobs/family/invitations/cleanup_job.rb @@ -4,6 +4,8 @@ class Family::Invitations::CleanupJob < ApplicationJob queue_as :families def perform + return unless DawarichSettings.family_feature_enabled? + Rails.logger.info 'Starting family invitations cleanup' expired_count = Family::Invitation.where(status: :pending) diff --git a/app/jobs/points/raw_data/archive_job.rb b/app/jobs/points/raw_data/archive_job.rb new file mode 100644 index 00000000..de788361 --- /dev/null +++ b/app/jobs/points/raw_data/archive_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Points + module RawData + class ArchiveJob < ApplicationJob + queue_as :archival + + def perform + return unless ENV['ARCHIVE_RAW_DATA'] == 'true' + + stats = Points::RawData::Archiver.new.call + + Rails.logger.info("Archive job complete: #{stats}") + rescue StandardError => e + ExceptionReporter.call(e, 'Points raw data archival job failed') + + raise + end + end + end +end diff --git a/app/jobs/points/raw_data/re_archive_month_job.rb b/app/jobs/points/raw_data/re_archive_month_job.rb new file mode 100644 index 00000000..a87db18e --- /dev/null +++ b/app/jobs/points/raw_data/re_archive_month_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Points + module RawData + class ReArchiveMonthJob < ApplicationJob + queue_as :archival + + def perform(user_id, year, month) + Rails.logger.info("Re-archiving #{user_id}/#{year}/#{month} (retrospective import)") + + Points::RawData::Archiver.new.archive_specific_month(user_id, year, month) + rescue StandardError => e + ExceptionReporter.call(e, "Re-archival job failed for #{user_id}/#{year}/#{month}") + + raise + end + end + end +end diff --git a/app/models/concerns/archivable.rb b/app/models/concerns/archivable.rb new file mode 100644 index 00000000..5af812ee --- /dev/null +++ b/app/models/concerns/archivable.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Archivable + extend ActiveSupport::Concern + + included do + belongs_to :raw_data_archive, + class_name: 'Points::RawDataArchive', + optional: true + + scope :archived, -> { where(raw_data_archived: true) } + scope :not_archived, -> { where(raw_data_archived: false) } + scope :with_archived_raw_data, lambda { + includes(raw_data_archive: { file_attachment: :blob }) + } + end + + # Main method: Get raw_data with fallback to archive + # Use this instead of point.raw_data when you need archived data + def raw_data_with_archive + return raw_data if raw_data.present? || !raw_data_archived? + + fetch_archived_raw_data + end + + # Restore archived data back to database column + def restore_raw_data!(value) + update!( + raw_data: value, + raw_data_archived: false, + raw_data_archive_id: nil + ) + end + + private + + def fetch_archived_raw_data + # Check temporary restore cache first (for migrations) + cached = check_temporary_restore_cache + return cached if cached + + fetch_from_archive_file + rescue StandardError => e + handle_archive_fetch_error(e) + end + + def check_temporary_restore_cache + return nil unless respond_to?(:timestamp) + + recorded_time = Time.at(timestamp) + cache_key = "raw_data:temp:#{user_id}:#{recorded_time.year}:#{recorded_time.month}:#{id}" + Rails.cache.read(cache_key) + end + + def fetch_from_archive_file + return {} unless raw_data_archive&.file&.attached? + + # Download and search through JSONL + compressed_content = raw_data_archive.file.blob.download + io = StringIO.new(compressed_content) + gz = Zlib::GzipReader.new(io) + + begin + result = nil + gz.each_line do |line| + data = JSON.parse(line) + if data['id'] == id + result = data['raw_data'] + break + end + end + result || {} + ensure + gz.close + end + end + + def handle_archive_fetch_error(error) + ExceptionReporter.call(error, "Failed to fetch archived raw_data for Point ID #{id}") + + {} # Graceful degradation + end +end diff --git a/app/models/concerns/taggable.rb b/app/models/concerns/taggable.rb index 59f97c91..2bebef4c 100644 --- a/app/models/concerns/taggable.rb +++ b/app/models/concerns/taggable.rb @@ -8,18 +8,18 @@ module Taggable has_many :tags, through: :taggings scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct } - scope :with_all_tags, ->(tag_ids) { - tag_ids = Array(tag_ids) + scope :with_all_tags, lambda { |tag_ids| + tag_ids = Array(tag_ids).uniq return none if tag_ids.empty? # For each tag, join and filter, then use HAVING to ensure all tags are present joins(:taggings) .where(taggings: { tag_id: tag_ids }) .group("#{table_name}.id") - .having("COUNT(DISTINCT taggings.tag_id) = ?", tag_ids.length) + .having('COUNT(DISTINCT taggings.tag_id) = ?', tag_ids.length) } scope :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) } - scope :tagged_with, ->(tag_name, user) { + scope :tagged_with, lambda { |tag_name, user| joins(:tags).where(tags: { name: tag_name, user: user }).distinct } end diff --git a/app/models/point.rb b/app/models/point.rb index b19e828d..cdc01712 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -3,6 +3,7 @@ class Point < ApplicationRecord include Nearable include Distanceable + include Archivable belongs_to :import, optional: true, counter_cache: true belongs_to :visit, optional: true diff --git a/app/models/points/raw_data_archive.rb b/app/models/points/raw_data_archive.rb new file mode 100644 index 00000000..4657e091 --- /dev/null +++ b/app/models/points/raw_data_archive.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Points + class RawDataArchive < ApplicationRecord + self.table_name = 'points_raw_data_archives' + + belongs_to :user + has_many :points, dependent: :nullify + + has_one_attached :file + + validates :year, :month, :chunk_number, :point_count, presence: true + validates :year, numericality: { greater_than: 1970, less_than: 2100 } + validates :month, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 12 } + validates :chunk_number, numericality: { greater_than: 0 } + validates :point_ids_checksum, presence: true + + scope :for_month, lambda { |user_id, year, month| + where(user_id: user_id, year: year, month: month) + .order(:chunk_number) + } + + scope :recent, -> { where('archived_at > ?', 30.days.ago) } + scope :old, -> { where('archived_at < ?', 1.year.ago) } + + def month_display + Date.new(year, month, 1).strftime('%B %Y') + end + + def filename + "raw_data_archives/#{user_id}/#{year}/#{format('%02d', month)}/#{format('%03d', chunk_number)}.jsonl.gz" + end + + def size_mb + return 0 unless file.attached? + + (file.blob.byte_size / 1024.0 / 1024.0).round(2) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 34a8ac3e..6a591451 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,6 +20,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :tags, dependent: :destroy has_many :trips, dependent: :destroy has_many :tracks, dependent: :destroy + has_many :raw_data_archives, class_name: 'Points::RawDataArchive', dependent: :destroy after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } diff --git a/app/services/exception_reporter.rb b/app/services/exception_reporter.rb index 667206a8..8cdd88f3 100644 --- a/app/services/exception_reporter.rb +++ b/app/services/exception_reporter.rb @@ -2,10 +2,14 @@ class ExceptionReporter def self.call(exception, human_message = 'Exception reported') - return unless DawarichSettings.self_hosted? + return if DawarichSettings.self_hosted? - Rails.logger.error "#{human_message}: #{exception.message}" - - Sentry.capture_exception(exception) + if exception.is_a?(Exception) + Rails.logger.error "#{human_message}: #{exception.message}" + Sentry.capture_exception(exception) + else + Rails.logger.error "#{exception}: #{human_message}" + Sentry.capture_message("#{exception}: #{human_message}") + end end end diff --git a/app/services/imports/source_detector.rb b/app/services/imports/source_detector.rb index d5b0a2c6..52a9818f 100644 --- a/app/services/imports/source_detector.rb +++ b/app/services/imports/source_detector.rb @@ -127,6 +127,15 @@ class Imports::SourceDetector else file_content end + + # Check if it's a KMZ file (ZIP archive) + if filename&.downcase&.end_with?('.kmz') + # KMZ files are ZIP archives, check for ZIP signature + # ZIP files start with "PK" (0x50 0x4B) + return content_to_check[0..1] == 'PK' + end + + # For KML files, check XML structure ( content_to_check.strip.start_with?(' e + raise "Failed to extract KML from KMZ: #{e.message}" + end + + def find_kml_in_zip(kmz_content) + kml_content = nil + + Zip::InputStream.open(StringIO.new(kmz_content)) do |io| + while (entry = io.get_next_entry) + if kml_entry?(entry) + kml_content = io.read + break + end end end - # Handle MultiGeometry (can contain multiple Points, LineStrings, etc.) + kml_content + end + + def kml_entry?(entry) + entry.name.downcase.end_with?('.kml') + end + + def parse_placemark(placemark) + return [] unless has_explicit_timestamp?(placemark) + + timestamp = extract_timestamp(placemark) + points = [] + + points.concat(extract_point_geometry(placemark, timestamp)) + points.concat(extract_linestring_geometry(placemark, timestamp)) + points.concat(extract_multigeometry(placemark, timestamp)) + + points.compact + end + + def extract_point_geometry(placemark, timestamp) + point_node = REXML::XPath.first(placemark, './/Point/coordinates') + return [] unless point_node + + coords = parse_coordinates(point_node.text) + coords.any? ? [build_point(coords.first, timestamp, placemark)] : [] + end + + def extract_linestring_geometry(placemark, timestamp) + linestring_node = REXML::XPath.first(placemark, './/LineString/coordinates') + return [] unless linestring_node + + coords = parse_coordinates(linestring_node.text) + coords.map { |coord| build_point(coord, timestamp, placemark) } + end + + def extract_multigeometry(placemark, timestamp) + points = [] REXML::XPath.each(placemark, './/MultiGeometry//coordinates') do |coords_node| coords = parse_coordinates(coords_node.text) coords.each do |coord| points << build_point(coord, timestamp, placemark) end end - - points.compact + points end def parse_gx_track(track) - # Google Earth Track extension with coordinated when/coord pairs - points = [] + timestamps = extract_gx_timestamps(track) + coordinates = extract_gx_coordinates(track) + build_gx_track_points(timestamps, coordinates) + end + + def extract_gx_timestamps(track) timestamps = [] REXML::XPath.each(track, './/when') do |when_node| timestamps << when_node.text.strip end + timestamps + end + def extract_gx_coordinates(track) coordinates = [] REXML::XPath.each(track, './/gx:coord') do |coord_node| coordinates << coord_node.text.strip end + coordinates + end - # Match timestamps with coordinates - [timestamps.size, coordinates.size].min.times do |i| - begin - time = Time.parse(timestamps[i]).to_i - coord_parts = coordinates[i].split(/\s+/) - next if coord_parts.size < 2 + def build_gx_track_points(timestamps, coordinates) + points = [] + min_size = [timestamps.size, coordinates.size].min - lng, lat, alt = coord_parts.map(&:to_f) - - points << { - lonlat: "POINT(#{lng} #{lat})", - altitude: alt&.to_i || 0, - timestamp: time, - import_id: import.id, - velocity: 0.0, - raw_data: { source: 'gx_track', index: i }, - user_id: user_id, - created_at: Time.current, - updated_at: Time.current - } - rescue StandardError => e - Rails.logger.warn("Failed to parse gx:Track point at index #{i}: #{e.message}") - next - end + min_size.times do |i| + point = build_gx_track_point(timestamps[i], coordinates[i], i) + points << point if point end points end + def build_gx_track_point(timestamp_str, coord_str, index) + time = Time.parse(timestamp_str).to_i + coord_parts = coord_str.split(/\s+/) + return nil if coord_parts.size < 2 + + lng, lat, alt = coord_parts.map(&:to_f) + + { + lonlat: "POINT(#{lng} #{lat})", + altitude: alt&.to_i || 0, + timestamp: time, + import_id: import.id, + velocity: 0.0, + raw_data: { source: 'gx_track', index: index }, + user_id: user_id, + created_at: Time.current, + updated_at: Time.current + } + rescue StandardError => e + Rails.logger.warn("Failed to parse gx:Track point at index #{index}: #{e.message}") + nil + end + def parse_coordinates(coord_text) - # KML coordinates format: "longitude,latitude[,altitude] ..." - # Multiple coordinates separated by whitespace return [] if coord_text.blank? - coord_text.strip.split(/\s+/).map do |coord_str| - parts = coord_str.split(',') - next if parts.size < 2 + coord_text.strip.split(/\s+/).map { |coord_str| parse_single_coordinate(coord_str) }.compact + end - { - lng: parts[0].to_f, - lat: parts[1].to_f, - alt: parts[2]&.to_f || 0.0 - } - end.compact + def parse_single_coordinate(coord_str) + parts = coord_str.split(',') + return nil if parts.size < 2 + + { + lng: parts[0].to_f, + lat: parts[1].to_f, + alt: parts[2]&.to_f || 0.0 + } + end + + def has_explicit_timestamp?(placemark) + find_timestamp_node(placemark).present? end def extract_timestamp(placemark) - # Try TimeStamp first - timestamp_node = REXML::XPath.first(placemark, './/TimeStamp/when') - return Time.parse(timestamp_node.text).to_i if timestamp_node + node = find_timestamp_node(placemark) + raise 'No timestamp found in placemark' unless node - # Try TimeSpan begin - timespan_begin = REXML::XPath.first(placemark, './/TimeSpan/begin') - return Time.parse(timespan_begin.text).to_i if timespan_begin - - # Try TimeSpan end as fallback - timespan_end = REXML::XPath.first(placemark, './/TimeSpan/end') - return Time.parse(timespan_end.text).to_i if timespan_end - - # Default to import creation time if no timestamp found - import.created_at.to_i + Time.parse(node.text).to_i rescue StandardError => e - Rails.logger.warn("Failed to parse timestamp: #{e.message}") - import.created_at.to_i + Rails.logger.error("Failed to parse timestamp: #{e.message}") + raise e + end + + def find_timestamp_node(placemark) + REXML::XPath.first(placemark, './/TimeStamp/when') || + REXML::XPath.first(placemark, './/TimeSpan/begin') || + REXML::XPath.first(placemark, './/TimeSpan/end') end def build_point(coord, timestamp, placemark) - return if coord[:lat].blank? || coord[:lng].blank? + return if invalid_coordinates?(coord) { - lonlat: "POINT(#{coord[:lng]} #{coord[:lat]})", + lonlat: format_point_geometry(coord), altitude: coord[:alt].to_i, timestamp: timestamp, import_id: import.id, @@ -169,31 +267,52 @@ class Kml::Importer } end + def invalid_coordinates?(coord) + coord[:lat].blank? || coord[:lng].blank? + end + + def format_point_geometry(coord) + "POINT(#{coord[:lng]} #{coord[:lat]})" + end + def extract_velocity(placemark) - # Try to extract speed from ExtendedData - speed_node = REXML::XPath.first(placemark, ".//Data[@name='speed']/value") || - REXML::XPath.first(placemark, ".//Data[@name='Speed']/value") || - REXML::XPath.first(placemark, ".//Data[@name='velocity']/value") - - return speed_node.text.to_f.round(1) if speed_node - - 0.0 + speed_node = find_speed_node(placemark) + speed_node ? speed_node.text.to_f.round(1) : 0.0 rescue StandardError 0.0 end + def find_speed_node(placemark) + REXML::XPath.first(placemark, ".//Data[@name='speed']/value") || + REXML::XPath.first(placemark, ".//Data[@name='Speed']/value") || + REXML::XPath.first(placemark, ".//Data[@name='velocity']/value") + end + def extract_extended_data(placemark) data = {} + data.merge!(extract_name_and_description(placemark)) + data.merge!(extract_custom_data_fields(placemark)) + data + rescue StandardError => e + Rails.logger.warn("Failed to extract extended data: #{e.message}") + {} + end + + def extract_name_and_description(placemark) + data = {} - # Extract name if present name_node = REXML::XPath.first(placemark, './/name') data['name'] = name_node.text.strip if name_node - # Extract description if present desc_node = REXML::XPath.first(placemark, './/description') data['description'] = desc_node.text.strip if desc_node - # Extract all ExtendedData/Data elements + data + end + + def extract_custom_data_fields(placemark) + data = {} + REXML::XPath.each(placemark, './/ExtendedData/Data') do |data_node| name = data_node.attributes['name'] value_node = REXML::XPath.first(data_node, './value') @@ -201,26 +320,29 @@ class Kml::Importer end data - rescue StandardError => e - Rails.logger.warn("Failed to extract extended data: #{e.message}") - {} end def bulk_insert_points(batch) - unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + unique_batch = deduplicate_batch(batch) + upsert_points(unique_batch) + broadcast_import_progress(import, unique_batch.size) + rescue StandardError => e + create_notification("Failed to process KML file: #{e.message}") + end + def deduplicate_batch(batch) + batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + end + + def upsert_points(batch) # rubocop:disable Rails/SkipsModelValidations Point.upsert_all( - unique_batch, + batch, unique_by: %i[lonlat timestamp user_id], returning: false, on_duplicate: :skip ) # rubocop:enable Rails/SkipsModelValidations - - broadcast_import_progress(import, unique_batch.size) - rescue StandardError => e - create_notification("Failed to process KML file: #{e.message}") end def create_notification(message) diff --git a/app/services/points/raw_data/archiver.rb b/app/services/points/raw_data/archiver.rb new file mode 100644 index 00000000..350a8c24 --- /dev/null +++ b/app/services/points/raw_data/archiver.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +module Points + module RawData + class Archiver + SAFE_ARCHIVE_LAG = 2.months + + def initialize + @stats = { processed: 0, archived: 0, failed: 0 } + end + + def call + unless archival_enabled? + Rails.logger.info('Raw data archival disabled (ARCHIVE_RAW_DATA != "true")') + return @stats + end + + Rails.logger.info('Starting points raw_data archival...') + + archivable_months.each do |month_data| + process_month(month_data) + end + + Rails.logger.info("Archival complete: #{@stats}") + @stats + end + + def archive_specific_month(user_id, year, month) + month_data = { + 'user_id' => user_id, + 'year' => year, + 'month' => month + } + + process_month(month_data) + end + + private + + def archival_enabled? + ENV['ARCHIVE_RAW_DATA'] == 'true' + end + + def archivable_months + # Only months 2+ months old with unarchived points + safe_cutoff = Date.current.beginning_of_month - SAFE_ARCHIVE_LAG + + # Use raw SQL to avoid GROUP BY issues with ActiveRecord + # Use AT TIME ZONE 'UTC' to ensure consistent timezone handling + sql = <<-SQL.squish + SELECT user_id, + EXTRACT(YEAR FROM (to_timestamp(timestamp) AT TIME ZONE 'UTC'))::int as year, + EXTRACT(MONTH FROM (to_timestamp(timestamp) AT TIME ZONE 'UTC'))::int as month, + COUNT(*) as unarchived_count + FROM points + WHERE raw_data_archived = false + AND raw_data IS NOT NULL + AND raw_data != '{}' + AND to_timestamp(timestamp) < ? + GROUP BY user_id, + EXTRACT(YEAR FROM (to_timestamp(timestamp) AT TIME ZONE 'UTC')), + EXTRACT(MONTH FROM (to_timestamp(timestamp) AT TIME ZONE 'UTC')) + SQL + + ActiveRecord::Base.connection.exec_query( + ActiveRecord::Base.sanitize_sql_array([sql, safe_cutoff]) + ) + end + + def process_month(month_data) + user_id = month_data['user_id'] + year = month_data['year'] + month = month_data['month'] + + lock_key = "archive_points:#{user_id}:#{year}:#{month}" + + # Advisory lock prevents duplicate processing + # Returns false if lock couldn't be acquired (already locked) + lock_acquired = ActiveRecord::Base.with_advisory_lock(lock_key, timeout_seconds: 0) do + archive_month(user_id, year, month) + @stats[:processed] += 1 + true + end + + Rails.logger.info("Skipping #{lock_key} - already locked") unless lock_acquired + rescue StandardError => e + ExceptionReporter.call(e, "Failed to archive points for user #{user_id}, #{year}-#{month}") + + @stats[:failed] += 1 + end + + def archive_month(user_id, year, month) + points = find_archivable_points(user_id, year, month) + return if points.empty? + + point_ids = points.pluck(:id) + log_archival_start(user_id, year, month, point_ids.count) + + archive = create_archive_chunk(user_id, year, month, points, point_ids) + mark_points_as_archived(point_ids, archive.id) + update_stats(point_ids.count) + log_archival_success(archive) + end + + def find_archivable_points(user_id, year, month) + timestamp_range = month_timestamp_range(year, month) + + Point.where(user_id: user_id, raw_data_archived: false) + .where(timestamp: timestamp_range) + .where.not(raw_data: nil) + .where.not(raw_data: '{}') + end + + def month_timestamp_range(year, month) + start_of_month = Time.utc(year, month, 1).to_i + end_of_month = (Time.utc(year, month, 1) + 1.month).to_i + start_of_month...end_of_month + end + + def mark_points_as_archived(point_ids, archive_id) + Point.transaction do + Point.where(id: point_ids).update_all( + raw_data_archived: true, + raw_data_archive_id: archive_id + ) + end + end + + def update_stats(archived_count) + @stats[:archived] += archived_count + end + + def log_archival_start(user_id, year, month, count) + Rails.logger.info("Archiving #{count} points for user #{user_id}, #{year}-#{format('%02d', month)}") + end + + def log_archival_success(archive) + Rails.logger.info("✓ Archived chunk #{archive.chunk_number} (#{archive.size_mb} MB)") + end + + def create_archive_chunk(user_id, year, month, points, point_ids) + # Determine chunk number (append-only) + chunk_number = Points::RawDataArchive + .where(user_id: user_id, year: year, month: month) + .maximum(:chunk_number).to_i + 1 + + # Compress points data + compressed_data = Points::RawData::ChunkCompressor.new(points).compress + + # Create archive record + archive = Points::RawDataArchive.create!( + user_id: user_id, + year: year, + month: month, + chunk_number: chunk_number, + point_count: point_ids.count, + point_ids_checksum: calculate_checksum(point_ids), + archived_at: Time.current, + metadata: { + format_version: 1, + compression: 'gzip', + archived_by: 'Points::RawData::Archiver' + } + ) + + # Attach compressed file via ActiveStorage + # Uses directory structure: raw_data_archives/:user_id/:year/:month/:chunk.jsonl.gz + # The key parameter controls the actual storage path + archive.file.attach( + io: StringIO.new(compressed_data), + filename: "#{format('%03d', chunk_number)}.jsonl.gz", + content_type: 'application/gzip', + key: "raw_data_archives/#{user_id}/#{year}/#{format('%02d', month)}/#{format('%03d', chunk_number)}.jsonl.gz" + ) + + archive + end + + def calculate_checksum(point_ids) + Digest::SHA256.hexdigest(point_ids.sort.join(',')) + end + end + end +end diff --git a/app/services/points/raw_data/chunk_compressor.rb b/app/services/points/raw_data/chunk_compressor.rb new file mode 100644 index 00000000..bf26e66b --- /dev/null +++ b/app/services/points/raw_data/chunk_compressor.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Points + module RawData + class ChunkCompressor + def initialize(points_relation) + @points = points_relation + end + + def compress + io = StringIO.new + gz = Zlib::GzipWriter.new(io) + + # Stream points to avoid memory issues with large months + @points.select(:id, :raw_data).find_each(batch_size: 1000) do |point| + # Write as JSONL (one JSON object per line) + gz.puts({ id: point.id, raw_data: point.raw_data }.to_json) + end + + gz.close + io.string.force_encoding(Encoding::ASCII_8BIT) # Returns compressed bytes in binary encoding + end + end + end +end diff --git a/app/services/points/raw_data/clearer.rb b/app/services/points/raw_data/clearer.rb new file mode 100644 index 00000000..46187824 --- /dev/null +++ b/app/services/points/raw_data/clearer.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Points + module RawData + class Clearer + BATCH_SIZE = 10_000 + + def initialize + @stats = { cleared: 0, skipped: 0 } + end + + def call + Rails.logger.info('Starting raw_data clearing for verified archives...') + + verified_archives.find_each do |archive| + clear_archive_points(archive) + end + + Rails.logger.info("Clearing complete: #{@stats}") + @stats + end + + def clear_specific_archive(archive_id) + archive = Points::RawDataArchive.find(archive_id) + + unless archive.verified_at.present? + Rails.logger.warn("Archive #{archive_id} not verified, skipping clear") + return { cleared: 0, skipped: 0 } + end + + clear_archive_points(archive) + end + + def clear_month(user_id, year, month) + archives = Points::RawDataArchive.for_month(user_id, year, month) + .where.not(verified_at: nil) + + Rails.logger.info("Clearing #{archives.count} verified archives for #{year}-#{format('%02d', month)}...") + + archives.each { |archive| clear_archive_points(archive) } + end + + private + + def verified_archives + # Only archives that are verified but have points with non-empty raw_data + Points::RawDataArchive + .where.not(verified_at: nil) + .where(id: points_needing_clearing.select(:raw_data_archive_id).distinct) + end + + def points_needing_clearing + Point.where(raw_data_archived: true) + .where.not(raw_data: {}) + .where.not(raw_data_archive_id: nil) + end + + def clear_archive_points(archive) + Rails.logger.info( + "Clearing points for archive #{archive.id} " \ + "(#{archive.month_display}, chunk #{archive.chunk_number})..." + ) + + point_ids = Point.where(raw_data_archive_id: archive.id) + .where(raw_data_archived: true) + .where.not(raw_data: {}) + .pluck(:id) + + if point_ids.empty? + Rails.logger.info("No points to clear for archive #{archive.id}") + return + end + + cleared_count = clear_points_in_batches(point_ids) + @stats[:cleared] += cleared_count + Rails.logger.info("✓ Cleared #{cleared_count} points for archive #{archive.id}") + rescue StandardError => e + ExceptionReporter.call(e, "Failed to clear points for archive #{archive.id}") + Rails.logger.error("✗ Failed to clear archive #{archive.id}: #{e.message}") + end + + def clear_points_in_batches(point_ids) + total_cleared = 0 + + point_ids.each_slice(BATCH_SIZE) do |batch| + Point.transaction do + Point.where(id: batch).update_all(raw_data: {}) + total_cleared += batch.size + end + end + + total_cleared + end + end + end +end diff --git a/app/services/points/raw_data/restorer.rb b/app/services/points/raw_data/restorer.rb new file mode 100644 index 00000000..004f7185 --- /dev/null +++ b/app/services/points/raw_data/restorer.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Points + module RawData + class Restorer + def restore_to_database(user_id, year, month) + archives = Points::RawDataArchive.for_month(user_id, year, month) + + raise "No archives found for user #{user_id}, #{year}-#{month}" if archives.empty? + + Rails.logger.info("Restoring #{archives.count} archives to database...") + + Point.transaction do + archives.each { restore_archive_to_db(_1) } + end + + Rails.logger.info("✓ Restored #{archives.sum(:point_count)} points") + end + + def restore_to_memory(user_id, year, month) + archives = Points::RawDataArchive.for_month(user_id, year, month) + + raise "No archives found for user #{user_id}, #{year}-#{month}" if archives.empty? + + Rails.logger.info("Loading #{archives.count} archives into cache...") + + cache_key_prefix = "raw_data:temp:#{user_id}:#{year}:#{month}" + count = 0 + + archives.each do |archive| + count += restore_archive_to_cache(archive, cache_key_prefix) + end + + Rails.logger.info("✓ Loaded #{count} points into cache (expires in 1 hour)") + end + + def restore_all_for_user(user_id) + archives = + Points::RawDataArchive.where(user_id: user_id) + .select(:year, :month) + .distinct + .order(:year, :month) + + Rails.logger.info("Restoring #{archives.count} months for user #{user_id}...") + + archives.each do |archive| + restore_to_database(user_id, archive.year, archive.month) + end + + Rails.logger.info('✓ Complete user restore finished') + end + + private + + def restore_archive_to_db(archive) + decompressed = download_and_decompress(archive) + + decompressed.each_line do |line| + data = JSON.parse(line) + + Point.where(id: data['id']).update_all( + raw_data: data['raw_data'], + raw_data_archived: false, + raw_data_archive_id: nil + ) + end + end + + def restore_archive_to_cache(archive, cache_key_prefix) + decompressed = download_and_decompress(archive) + count = 0 + + decompressed.each_line do |line| + data = JSON.parse(line) + + Rails.cache.write( + "#{cache_key_prefix}:#{data['id']}", + data['raw_data'], + expires_in: 1.hour + ) + + count += 1 + end + + count + end + + def download_and_decompress(archive) + # Download via ActiveStorage + compressed_content = archive.file.blob.download + + # Decompress + io = StringIO.new(compressed_content) + gz = Zlib::GzipReader.new(io) + content = gz.read + gz.close + + content + rescue StandardError => e + Rails.logger.error("Failed to download/decompress archive #{archive.id}: #{e.message}") + raise + end + end + end +end diff --git a/app/services/points/raw_data/verifier.rb b/app/services/points/raw_data/verifier.rb new file mode 100644 index 00000000..de42229f --- /dev/null +++ b/app/services/points/raw_data/verifier.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module Points + module RawData + class Verifier + def initialize + @stats = { verified: 0, failed: 0 } + end + + def call + Rails.logger.info('Starting raw_data archive verification...') + + unverified_archives.find_each do |archive| + verify_archive(archive) + end + + Rails.logger.info("Verification complete: #{@stats}") + @stats + end + + def verify_specific_archive(archive_id) + archive = Points::RawDataArchive.find(archive_id) + verify_archive(archive) + end + + def verify_month(user_id, year, month) + archives = Points::RawDataArchive.for_month(user_id, year, month) + .where(verified_at: nil) + + Rails.logger.info("Verifying #{archives.count} archives for #{year}-#{format('%02d', month)}...") + + archives.each { |archive| verify_archive(archive) } + end + + private + + def unverified_archives + Points::RawDataArchive.where(verified_at: nil) + end + + def verify_archive(archive) + Rails.logger.info("Verifying archive #{archive.id} (#{archive.month_display}, chunk #{archive.chunk_number})...") + + verification_result = perform_verification(archive) + + if verification_result[:success] + archive.update!(verified_at: Time.current) + @stats[:verified] += 1 + Rails.logger.info("✓ Archive #{archive.id} verified successfully") + else + @stats[:failed] += 1 + Rails.logger.error("✗ Archive #{archive.id} verification failed: #{verification_result[:error]}") + ExceptionReporter.call( + StandardError.new(verification_result[:error]), + "Archive verification failed for archive #{archive.id}" + ) + end + rescue StandardError => e + @stats[:failed] += 1 + ExceptionReporter.call(e, "Failed to verify archive #{archive.id}") + Rails.logger.error("✗ Archive #{archive.id} verification error: #{e.message}") + end + + def perform_verification(archive) + # 1. Verify file exists and is attached + unless archive.file.attached? + return { success: false, error: 'File not attached' } + end + + # 2. Verify file can be downloaded + begin + compressed_content = archive.file.blob.download + rescue StandardError => e + return { success: false, error: "File download failed: #{e.message}" } + end + + # 3. Verify file size is reasonable + if compressed_content.bytesize.zero? + return { success: false, error: 'File is empty' } + end + + # 4. Verify MD5 checksum (if blob has checksum) + if archive.file.blob.checksum.present? + calculated_checksum = Digest::MD5.base64digest(compressed_content) + if calculated_checksum != archive.file.blob.checksum + return { success: false, error: 'MD5 checksum mismatch' } + end + end + + # 5. Verify file can be decompressed and is valid JSONL, extract data + begin + archived_data = decompress_and_extract_data(compressed_content) + rescue StandardError => e + return { success: false, error: "Decompression/parsing failed: #{e.message}" } + end + + point_ids = archived_data.keys + + # 6. Verify point count matches + if point_ids.count != archive.point_count + return { + success: false, + error: "Point count mismatch: expected #{archive.point_count}, found #{point_ids.count}" + } + end + + # 7. Verify point IDs checksum matches + calculated_checksum = calculate_checksum(point_ids) + if calculated_checksum != archive.point_ids_checksum + return { success: false, error: 'Point IDs checksum mismatch' } + end + + # 8. Verify all points still exist in database + existing_count = Point.where(id: point_ids).count + if existing_count != point_ids.count + return { + success: false, + error: "Missing points in database: expected #{point_ids.count}, found #{existing_count}" + } + end + + # 9. Verify archived raw_data matches current database raw_data + verification_result = verify_raw_data_matches(archived_data) + return verification_result unless verification_result[:success] + + { success: true } + end + + def decompress_and_extract_data(compressed_content) + io = StringIO.new(compressed_content) + gz = Zlib::GzipReader.new(io) + archived_data = {} + + gz.each_line do |line| + data = JSON.parse(line) + archived_data[data['id']] = data['raw_data'] + end + + gz.close + archived_data + end + + def verify_raw_data_matches(archived_data) + # For small archives, verify all points. For large archives, sample up to 100 points. + # Always verify all if 100 or fewer points for maximum accuracy + if archived_data.size <= 100 + point_ids_to_check = archived_data.keys + else + point_ids_to_check = archived_data.keys.sample(100) + end + + mismatches = [] + found_points = 0 + + Point.where(id: point_ids_to_check).find_each do |point| + found_points += 1 + archived_raw_data = archived_data[point.id] + current_raw_data = point.raw_data + + # Compare the raw_data (both should be hashes) + if archived_raw_data != current_raw_data + mismatches << { + point_id: point.id, + archived: archived_raw_data, + current: current_raw_data + } + end + end + + # Check if we found all the points we were looking for + if found_points != point_ids_to_check.size + return { + success: false, + error: "Missing points during data verification: expected #{point_ids_to_check.size}, found #{found_points}" + } + end + + if mismatches.any? + return { + success: false, + error: "Raw data mismatch detected in #{mismatches.count} point(s). " \ + "First mismatch: Point #{mismatches.first[:point_id]}" + } + end + + { success: true } + end + + def calculate_checksum(point_ids) + Digest::SHA256.hexdigest(point_ids.sort.join(',')) + end + end + end +end diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb index 311b0c26..ff02dbbe 100644 --- a/app/services/stats/calculate_month.rb +++ b/app/services/stats/calculate_month.rb @@ -66,8 +66,7 @@ class Stats::CalculateMonth .points .without_raw_data .where(timestamp: start_timestamp..end_timestamp) - .select(:city, :country_name) - .distinct + .select(:city, :country_name, :timestamp) CountriesAndCities.new(toponym_points).call end diff --git a/app/services/users/import_data.rb b/app/services/users/import_data.rb index 2daff4c2..ad877aa0 100644 --- a/app/services/users/import_data.rb +++ b/app/services/users/import_data.rb @@ -25,6 +25,7 @@ require 'oj' class Users::ImportData STREAM_BATCH_SIZE = 5000 STREAMED_SECTIONS = %w[places visits points].freeze + MAX_ENTRY_SIZE = 10.gigabytes # Maximum size for a single file in the archive def initialize(user, archive_path) @user = user @@ -86,12 +87,47 @@ class Users::ImportData Rails.logger.debug "Extracting #{entry.name} to #{extraction_path}" + # Validate entry size before extraction + if entry.size > MAX_ENTRY_SIZE + Rails.logger.error "Skipping oversized entry: #{entry.name} (#{entry.size} bytes exceeds #{MAX_ENTRY_SIZE} bytes)" + raise "Archive entry #{entry.name} exceeds maximum allowed size" + end + FileUtils.mkdir_p(File.dirname(extraction_path)) - entry.extract(sanitized_name, destination_directory: @import_directory) + + # Extract with proper error handling and cleanup + extract_entry_safely(entry, extraction_path) end end end + def extract_entry_safely(entry, extraction_path) + # Extract with error handling and cleanup on failure + begin + entry.get_input_stream do |input| + File.open(extraction_path, 'wb') do |output| + bytes_copied = IO.copy_stream(input, output) + + # Verify extracted size matches expected size + if bytes_copied != entry.size + raise "Size mismatch for #{entry.name}: expected #{entry.size} bytes, got #{bytes_copied} bytes" + end + end + end + + Rails.logger.debug "Successfully extracted #{entry.name} (#{entry.size} bytes)" + rescue StandardError => e + # Clean up partial file on error + FileUtils.rm_f(extraction_path) if File.exist?(extraction_path) + + Rails.logger.error "Failed to extract #{entry.name}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + + # Re-raise to stop the import process + raise "Extraction failed for #{entry.name}: #{e.message}" + end + end + def sanitize_zip_entry_name(entry_name) sanitized = entry_name.gsub(%r{^[/\\]+}, '') diff --git a/app/views/map/leaflet/index.html.erb b/app/views/map/leaflet/index.html.erb index 1086601b..08ea2110 100644 --- a/app/views/map/leaflet/index.html.erb +++ b/app/views/map/leaflet/index.html.erb @@ -17,12 +17,13 @@ data-tracks='<%= @tracks.to_json.html_safe %>' data-distance="<%= @distance %>" data-points_number="<%= @points_number %>" - data-timezone="<%= Rails.configuration.time_zone %>" + data-timezone="<%= current_user.timezone %>" data-features='<%= @features.to_json.html_safe %>' data-user_tags='<%= current_user.tags.ordered.select(:id, :name, :icon, :color).as_json.to_json.html_safe %>' data-home_coordinates='<%= @home_coordinates.to_json.html_safe %>' data-family-members-features-value='<%= @features.to_json.html_safe %>' - data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>"> + data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>" + data-family-members-timezone-value="<%= current_user.timezone %>">
    diff --git a/app/views/map/maplibre/index.html.erb b/app/views/map/maplibre/index.html.erb index 9d6bc8ac..961450b4 100644 --- a/app/views/map/maplibre/index.html.erb +++ b/app/views/map/maplibre/index.html.erb @@ -5,8 +5,9 @@
    diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index fcf869a7..40651c3d 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -131,30 +131,44 @@
    <% end %> - +
  • +
    + + <%= icon 'bell' %> + <% if @unread_notifications.present? %> + + <%= @unread_notifications.size %> + + <% end %> + +
      +
    • <%= link_to 'See all', notifications_path %>
    • + <% @unread_notifications.first(10).each do |notification| %> +
      +
    • + <%= link_to notification do %> + <%= notification.title %> +
      + <% end %> +
    • + <% end %> +
    +
    +
  • +
  • +
    + <%= icon 'message-circle-question-mark' %> +
      +
    • Need help? Ping us! <%= icon 'arrow-big-down' %>

    • +
    • <%= link_to 'X (Twitter)', 'https://x.com/freymakesstuff', target: '_blank', rel: 'noopener noreferrer' %>
    • +
    • <%= link_to 'Mastodon', 'https://mastodon.social/@dawarich', target: '_blank', rel: 'noopener noreferrer' %>
    • +
    • <%= link_to 'Email', 'mailto:hi@dawarich.app' %>
    • +
    • <%= link_to 'Forum', 'https://discourse.dawarich.app', target: '_blank', rel: 'noopener noreferrer' %>
    • +
    • <%= link_to 'Discord', 'https://discord.gg/pHsBjpt5J8', target: '_blank', rel: 'noopener noreferrer' %>
    • +
    +
    +
  • diff --git a/app/views/stats/public_month.html.erb b/app/views/stats/public_month.html.erb index 560d285f..7ca4ff69 100644 --- a/app/views/stats/public_month.html.erb +++ b/app/views/stats/public_month.html.erb @@ -63,7 +63,8 @@ data-public-stat-map-uuid-value="<%= @stat.sharing_uuid %>" data-public-stat-map-data-bounds-value="<%= @data_bounds.to_json if @data_bounds %>" data-public-stat-map-hexagons-available-value="<%= @hexagons_available.to_s %>" - data-public-stat-map-self-hosted-value="<%= @self_hosted %>"> + data-public-stat-map-self-hosted-value="<%= @self_hosted %>" + data-public-stat-map-timezone-value="<%= @user.timezone %>">
    diff --git a/app/views/trips/_form.html.erb b/app/views/trips/_form.html.erb index 2d1420af..047f0956 100644 --- a/app/views/trips/_form.html.erb +++ b/app/views/trips/_form.html.erb @@ -22,7 +22,7 @@ data-path="<%= trip.path.to_json %>" data-started_at="<%= trip.started_at %>" data-ended_at="<%= trip.ended_at %>" - data-timezone="<%= Rails.configuration.time_zone %>"> + data-timezone="<%= current_user.timezone %>">
    diff --git a/app/views/trips/_path.html.erb b/app/views/trips/_path.html.erb index eb0679d2..b799e02f 100644 --- a/app/views/trips/_path.html.erb +++ b/app/views/trips/_path.html.erb @@ -9,7 +9,7 @@ data-path="<%= trip.path.coordinates.to_json %>" data-started_at="<%= trip.started_at %>" data-ended_at="<%= trip.ended_at %>" - data-timezone="<%= Rails.configuration.time_zone %>"> + data-timezone="<%= trip.user.timezone %>">
    diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb index 87a226b5..2f70ee2b 100644 --- a/app/views/trips/_trip.html.erb +++ b/app/views/trips/_trip.html.erb @@ -16,7 +16,7 @@ data-trip-map-path-value="<%= trip.path.coordinates.to_json %>" data-trip-map-api-key-value="<%= current_user.api_key %>" data-trip-map-user-settings-value="<%= current_user.safe_settings.settings.to_json %>" - data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>"> + data-trip-map-timezone-value="<%= trip.user.timezone %>"> diff --git a/config/application.rb b/config/application.rb index 58530149..bed4e260 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,6 +37,6 @@ module Dawarich config.active_job.queue_adapter = :sidekiq - config.action_mailer.preview_paths << "#{Rails.root}/spec/mailers/previews" + config.action_mailer.preview_paths << "#{Rails.root.join('spec/mailers/previews')}" end end diff --git a/config/environments/production.rb b/config/environments/production.rb index 8dd9762f..dc2a96af 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -103,7 +103,7 @@ Rails.application.configure do # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` # ] # Skip DNS rebinding protection for the health check endpoint. - config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } } + config.host_authorization = { exclude: ->(request) { request.path == '/api/v1/health' } } hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip) config.action_mailer.default_url_options = { host: ENV['DOMAIN'] } diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb index f7b0ba98..b5ec3649 100644 --- a/config/initializers/01_constants.rb +++ b/config/initializers/01_constants.rb @@ -62,3 +62,6 @@ OIDC_AUTO_REGISTER = ENV.fetch('OIDC_AUTO_REGISTER', 'true') == 'true' # Email/password registration setting (default: false for self-hosted, true for cloud) ALLOW_EMAIL_PASSWORD_REGISTRATION = ENV.fetch('ALLOW_EMAIL_PASSWORD_REGISTRATION', 'false') == 'true' + +# Raw data archival setting +ARCHIVE_RAW_DATA = ENV.fetch('ARCHIVE_RAW_DATA', 'false') == 'true' diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb index 89a49267..2bc7cf4c 100644 --- a/config/initializers/03_dawarich_settings.rb +++ b/config/initializers/03_dawarich_settings.rb @@ -49,5 +49,9 @@ class DawarichSettings family: family_feature_enabled? } end + + def archive_raw_data_enabled? + @archive_raw_data_enabled ||= ARCHIVE_RAW_DATA + end end end diff --git a/config/initializers/aws.rb b/config/initializers/aws.rb index e3378aa4..52f1a6e0 100644 --- a/config/initializers/aws.rb +++ b/config/initializers/aws.rb @@ -2,14 +2,17 @@ require 'aws-sdk-core' +# Support both AWS_ENDPOINT and AWS_ENDPOINT_URL for backwards compatibility +endpoint_url = ENV['AWS_ENDPOINT_URL'] || ENV['AWS_ENDPOINT'] + if ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && - ENV['AWS_ENDPOINT'] + endpoint_url Aws.config.update( { region: ENV['AWS_REGION'], - endpoint: ENV['AWS_ENDPOINT'], + endpoint: endpoint_url, credentials: Aws::Credentials.new(ENV['AWS_ACCESS_KEY_ID'], ENV['AWS_SECRET_ACCESS_KEY']) } ) diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 36994f83..eab639c2 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -24,7 +24,7 @@ Sidekiq.configure_server do |config| end Sidekiq.configure_client do |config| - config.redis = { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_JOB_QUEUE_DB', 1)}" } + config.redis = { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_JOB_QUEUE_DB', 1) } end Sidekiq::Queue['reverse_geocoding'].limit = 1 if Sidekiq.server? && DawarichSettings.photon_uses_komoot_io? diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 5f2e133e..a4464488 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -16,3 +16,4 @@ - places - app_version_checking - cache + - archival diff --git a/config/storage.yml b/config/storage.yml index 78b402ab..af5033b4 100644 --- a/config/storage.yml +++ b/config/storage.yml @@ -7,14 +7,16 @@ local: root: <%= Rails.root.join("storage") %> # Only load S3 config if not in test environment -<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] && ENV['AWS_ENDPOINT_URL'] %> +# Support both AWS_ENDPOINT and AWS_ENDPOINT_URL for backwards compatibility +<% endpoint_url = ENV['AWS_ENDPOINT_URL'] || ENV['AWS_ENDPOINT'] %> +<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] && endpoint_url %> s3: service: S3 access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID") %> secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY") %> region: <%= ENV.fetch("AWS_REGION") %> bucket: <%= ENV.fetch("AWS_BUCKET") %> - endpoint: <%= ENV.fetch("AWS_ENDPOINT_URL") %> + endpoint: <%= endpoint_url %> <% end %> # Remember not to checkin your GCS keyfile to a repository diff --git a/db/migrate/20251206000001_create_points_raw_data_archives.rb b/db/migrate/20251206000001_create_points_raw_data_archives.rb new file mode 100644 index 00000000..59990482 --- /dev/null +++ b/db/migrate/20251206000001_create_points_raw_data_archives.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class CreatePointsRawDataArchives < ActiveRecord::Migration[8.0] + def change + create_table :points_raw_data_archives do |t| + t.bigint :user_id, null: false + t.integer :year, null: false + t.integer :month, null: false + t.integer :chunk_number, null: false, default: 1 + t.integer :point_count, null: false + t.string :point_ids_checksum, null: false + t.jsonb :metadata, default: {}, null: false + t.datetime :archived_at, null: false + + t.timestamps + end + + add_index :points_raw_data_archives, :user_id + add_index :points_raw_data_archives, [:user_id, :year, :month] + add_index :points_raw_data_archives, :archived_at + add_foreign_key :points_raw_data_archives, :users, validate: false + end +end diff --git a/db/migrate/20251206000002_add_archival_columns_to_points.rb b/db/migrate/20251206000002_add_archival_columns_to_points.rb new file mode 100644 index 00000000..89c28ec3 --- /dev/null +++ b/db/migrate/20251206000002_add_archival_columns_to_points.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddArchivalColumnsToPoints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :points, :raw_data_archived, :boolean, default: false, null: false + add_column :points, :raw_data_archive_id, :bigint, null: true + + add_index :points, :raw_data_archived, + where: 'raw_data_archived = true', + name: 'index_points_on_archived_true', + algorithm: :concurrently + add_index :points, :raw_data_archive_id, + algorithm: :concurrently + + add_foreign_key :points, :points_raw_data_archives, + column: :raw_data_archive_id, + on_delete: :nullify, # Don't delete points if archive deleted + validate: false + end +end diff --git a/db/migrate/20251206000004_validate_archival_foreign_keys.rb b/db/migrate/20251206000004_validate_archival_foreign_keys.rb new file mode 100644 index 00000000..6bd51526 --- /dev/null +++ b/db/migrate/20251206000004_validate_archival_foreign_keys.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ValidateArchivalForeignKeys < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :points_raw_data_archives, :users + validate_foreign_key :points, :points_raw_data_archives + end +end diff --git a/db/migrate/20251208210410_add_composite_index_to_stats.rb b/db/migrate/20251208210410_add_composite_index_to_stats.rb new file mode 100644 index 00000000..7f82a326 --- /dev/null +++ b/db/migrate/20251208210410_add_composite_index_to_stats.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddCompositeIndexToStats < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + # Add composite index for the most common stats lookup pattern: + # Stat.find_or_initialize_by(year:, month:, user:) + # This query is called on EVERY stats calculation + # + # Using algorithm: :concurrently to avoid locking the table during index creation + # This is crucial for production deployments with existing data + add_index :stats, %i[user_id year month], + name: 'index_stats_on_user_id_year_month', + unique: true, + algorithm: :concurrently + end +end diff --git a/db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb b/db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb new file mode 100644 index 00000000..face565d --- /dev/null +++ b/db/migrate/20251210193532_add_verified_at_to_points_raw_data_archives.rb @@ -0,0 +1,5 @@ +class AddVerifiedAtToPointsRawDataArchives < ActiveRecord::Migration[8.0] + def change + add_column :points_raw_data_archives, :verified_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 2bf29bb3..0968224f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_12_01_192510) do +ActiveRecord::Schema[8.0].define(version: 2025_12_10_193532) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -224,6 +224,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_01_192510) do t.bigint "country_id" t.bigint "track_id" t.string "country_name" + t.boolean "raw_data_archived", default: false, null: false + t.bigint "raw_data_archive_id" t.index ["altitude"], name: "index_points_on_altitude" t.index ["battery"], name: "index_points_on_battery" t.index ["battery_status"], name: "index_points_on_battery_status" @@ -238,6 +240,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_01_192510) do t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude" t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist + t.index ["raw_data_archive_id"], name: "index_points_on_raw_data_archive_id" + t.index ["raw_data_archived"], name: "index_points_on_archived_true", where: "(raw_data_archived = true)" t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at" t.index ["timestamp"], name: "index_points_on_timestamp" t.index ["track_id"], name: "index_points_on_track_id" @@ -249,6 +253,23 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_01_192510) do t.index ["visit_id"], name: "index_points_on_visit_id" end + create_table "points_raw_data_archives", force: :cascade do |t| + t.bigint "user_id", null: false + t.integer "year", null: false + t.integer "month", null: false + t.integer "chunk_number", default: 1, null: false + t.integer "point_count", null: false + t.string "point_ids_checksum", null: false + t.jsonb "metadata", default: {}, null: false + t.datetime "archived_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "verified_at" + t.index ["archived_at"], name: "index_points_raw_data_archives_on_archived_at" + t.index ["user_id", "year", "month"], name: "index_points_raw_data_archives_on_user_id_and_year_and_month" + t.index ["user_id"], name: "index_points_raw_data_archives_on_user_id" + end + create_table "stats", force: :cascade do |t| t.integer "year", null: false t.integer "month", null: false @@ -265,6 +286,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_01_192510) do t.index ["h3_hex_ids"], name: "index_stats_on_h3_hex_ids", where: "((h3_hex_ids IS NOT NULL) AND (h3_hex_ids <> '{}'::jsonb))", using: :gin t.index ["month"], name: "index_stats_on_month" t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true + t.index ["user_id", "year", "month"], name: "index_stats_on_user_id_year_month", unique: true t.index ["user_id"], name: "index_stats_on_user_id" t.index ["year"], name: "index_stats_on_year" end @@ -351,6 +373,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_01_192510) do t.string "utm_term" t.string "utm_content" t.index ["email"], name: "index_users_on_email", unique: true + t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end @@ -384,8 +407,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_01_192510) do add_foreign_key "notifications", "users" add_foreign_key "place_visits", "places" add_foreign_key "place_visits", "visits" + add_foreign_key "points", "points_raw_data_archives", column: "raw_data_archive_id", on_delete: :nullify add_foreign_key "points", "users" add_foreign_key "points", "visits" + add_foreign_key "points_raw_data_archives", "users" add_foreign_key "stats", "users" add_foreign_key "taggings", "tags" add_foreign_key "tags", "users" diff --git a/e2e/v2/map/layers/points.spec.js b/e2e/v2/map/layers/points.spec.js index 30843556..c11bcf6f 100644 --- a/e2e/v2/map/layers/points.spec.js +++ b/e2e/v2/map/layers/points.spec.js @@ -4,7 +4,8 @@ import { navigateToMapsV2WithDate, waitForLoadingComplete, hasLayer, - getPointsSourceData + getPointsSourceData, + getRoutesSourceData } from '../../helpers/setup.js' test.describe('Points Layer', () => { @@ -68,4 +69,424 @@ test.describe('Points Layer', () => { } }) }) + + test.describe('Dragging', () => { + test('allows dragging points to new positions', async ({ page }) => { + // Wait for points to load + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre') + const source = controller?.map?.getSource('points-source') + return source?._data?.features?.length > 0 + }, { timeout: 15000 }) + + // Get initial point data + const initialData = await getPointsSourceData(page) + expect(initialData.features.length).toBeGreaterThan(0) + + + // Get the map canvas bounds + const canvas = page.locator('.maplibregl-canvas') + const canvasBounds = await canvas.boundingBox() + expect(canvasBounds).not.toBeNull() + + // Ensure points layer is visible before testing dragging + const layerState = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + const pointsLayer = controller?.layerManager?.layers?.pointsLayer + + if (!pointsLayer) { + return { exists: false, visibleBefore: false, visibleAfter: false, draggingEnabled: false } + } + + const visibilityBefore = controller.map.getLayoutProperty('points', 'visibility') + const isVisibleBefore = visibilityBefore === 'visible' || visibilityBefore === undefined + + // If not visible, make it visible + if (!isVisibleBefore) { + pointsLayer.show() + } + + // Check again after calling show + const visibilityAfter = controller.map.getLayoutProperty('points', 'visibility') + const isVisibleAfter = visibilityAfter === 'visible' || visibilityAfter === undefined + + return { + exists: true, + visibleBefore: isVisibleBefore, + visibleAfter: isVisibleAfter, + draggingEnabled: pointsLayer.draggingEnabled || false + } + }) + + + // Wait longer for layer to render after visibility change + await page.waitForTimeout(2000) + + // Find a rendered point feature on the map and get its pixel coordinates + const renderedPoint = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + + // Get all rendered point features + const features = controller.map.queryRenderedFeatures(undefined, { layers: ['points'] }) + + if (features.length === 0) { + return { found: false, totalFeatures: 0 } + } + + // Pick the first rendered point + const feature = features[0] + const coords = feature.geometry.coordinates + const point = controller.map.project(coords) + + // Get the canvas position on the page + const canvas = controller.map.getCanvas() + const rect = canvas.getBoundingClientRect() + + return { + found: true, + totalFeatures: features.length, + pointId: feature.properties.id, + coords: coords, + x: point.x, + y: point.y, + pageX: rect.left + point.x, + pageY: rect.top + point.y + } + }) + + + expect(renderedPoint.found).toBe(true) + expect(renderedPoint.totalFeatures).toBeGreaterThan(0) + + const pointId = renderedPoint.pointId + const initialCoords = renderedPoint.coords + const pointPixel = { + x: renderedPoint.x, + y: renderedPoint.y, + pageX: renderedPoint.pageX, + pageY: renderedPoint.pageY + } + + + // Drag the point by 100 pixels to the right and 100 down (larger movement for visibility) + const dragOffset = { x: 100, y: 100 } + const startX = pointPixel.pageX + const startY = pointPixel.pageY + const endX = startX + dragOffset.x + const endY = startY + dragOffset.y + + + // Check cursor style on hover + await page.mouse.move(startX, startY) + await page.waitForTimeout(200) + + const cursorStyle = await page.evaluate(() => { + const canvas = document.querySelector('.maplibregl-canvas-container') + return window.getComputedStyle(canvas).cursor + }) + + // Perform the drag operation with slower movement + await page.mouse.down() + await page.waitForTimeout(100) + await page.mouse.move(endX, endY, { steps: 20 }) + await page.waitForTimeout(100) + await page.mouse.up() + + // Wait for API call to complete + await page.waitForTimeout(3000) + + // Get updated point data + const updatedData = await getPointsSourceData(page) + const updatedPoint = updatedData.features.find(f => f.properties.id === pointId) + + expect(updatedPoint).toBeDefined() + const updatedCoords = updatedPoint.geometry.coordinates + + + // Verify the point has moved (parse coordinates as numbers) + const updatedLng = parseFloat(updatedCoords[0]) + const updatedLat = parseFloat(updatedCoords[1]) + const initialLng = parseFloat(initialCoords[0]) + const initialLat = parseFloat(initialCoords[1]) + + expect(updatedLng).not.toBeCloseTo(initialLng, 5) + expect(updatedLat).not.toBeCloseTo(initialLat, 5) + }) + + test('updates connected route segments when point is dragged', async ({ page }) => { + // Wait for both points and routes to load + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre') + const pointsSource = controller?.map?.getSource('points-source') + const routesSource = controller?.map?.getSource('routes-source') + return pointsSource?._data?.features?.length > 0 && + routesSource?._data?.features?.length > 0 + }, { timeout: 15000 }) + + // Ensure points layer is visible + await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + const pointsLayer = controller?.layerManager?.layers?.pointsLayer + if (pointsLayer) { + const visibility = controller.map.getLayoutProperty('points', 'visibility') + if (visibility === 'none') { + pointsLayer.show() + } + } + }) + + await page.waitForTimeout(2000) + + // Get initial data + const initialRoutesData = await getRoutesSourceData(page) + expect(initialRoutesData.features.length).toBeGreaterThan(0) + + // Find a rendered point feature on the map + const renderedPoint = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + + // Get all rendered point features + const features = controller.map.queryRenderedFeatures(undefined, { layers: ['points'] }) + + if (features.length === 0) { + return { found: false } + } + + // Pick the first rendered point + const feature = features[0] + const coords = feature.geometry.coordinates + const point = controller.map.project(coords) + + // Get the canvas position on the page + const canvas = controller.map.getCanvas() + const rect = canvas.getBoundingClientRect() + + return { + found: true, + pointId: feature.properties.id, + coords: coords, + x: point.x, + y: point.y, + pageX: rect.left + point.x, + pageY: rect.top + point.y + } + }) + + expect(renderedPoint.found).toBe(true) + + const pointId = renderedPoint.pointId + const initialCoords = renderedPoint.coords + const pointPixel = { + x: renderedPoint.x, + y: renderedPoint.y, + pageX: renderedPoint.pageX, + pageY: renderedPoint.pageY + } + + // Find routes that contain this point + const connectedRoutes = initialRoutesData.features.filter(route => { + return route.geometry.coordinates.some(coord => + Math.abs(coord[0] - initialCoords[0]) < 0.0001 && + Math.abs(coord[1] - initialCoords[1]) < 0.0001 + ) + }) + + + const dragOffset = { x: 100, y: 100 } + const startX = pointPixel.pageX + const startY = pointPixel.pageY + const endX = startX + dragOffset.x + const endY = startY + dragOffset.y + + // Perform drag with slower movement + await page.mouse.move(startX, startY) + await page.waitForTimeout(100) + await page.mouse.down() + await page.waitForTimeout(100) + await page.mouse.move(endX, endY, { steps: 20 }) + await page.waitForTimeout(100) + await page.mouse.up() + + // Wait for updates + await page.waitForTimeout(3000) + + // Get updated data + const updatedPointsData = await getPointsSourceData(page) + const updatedRoutesData = await getRoutesSourceData(page) + + const updatedPoint = updatedPointsData.features.find(f => f.properties.id === pointId) + const updatedCoords = updatedPoint.geometry.coordinates + + // Verify routes have been updated + const updatedConnectedRoutes = updatedRoutesData.features.filter(route => { + return route.geometry.coordinates.some(coord => + Math.abs(coord[0] - updatedCoords[0]) < 0.0001 && + Math.abs(coord[1] - updatedCoords[1]) < 0.0001 + ) + }) + + + // Routes that were originally connected should now be at the new position + if (connectedRoutes.length > 0) { + expect(updatedConnectedRoutes.length).toBeGreaterThan(0) + } + + // The point moved, so verify the coordinates actually changed + const lngChanged = Math.abs(parseFloat(updatedCoords[0]) - initialCoords[0]) > 0.0001 + const latChanged = Math.abs(parseFloat(updatedCoords[1]) - initialCoords[1]) > 0.0001 + + expect(lngChanged || latChanged).toBe(true) + + // Since the route segments update is best-effort (depends on coordinate matching), + // we'll just verify that routes exist and the point moved + }) + + test('persists point position after page reload', async ({ page }) => { + // Wait for points to load + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre') + const source = controller?.map?.getSource('points-source') + return source?._data?.features?.length > 0 + }, { timeout: 15000 }) + + // Ensure points layer is visible + await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + const pointsLayer = controller?.layerManager?.layers?.pointsLayer + if (pointsLayer) { + const visibility = controller.map.getLayoutProperty('points', 'visibility') + if (visibility === 'none') { + pointsLayer.show() + } + } + }) + + await page.waitForTimeout(2000) + + // Find a rendered point feature on the map + const renderedPoint = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app.getControllerForElementAndIdentifier(element, 'maps--maplibre') + + // Get all rendered point features + const features = controller.map.queryRenderedFeatures(undefined, { layers: ['points'] }) + + if (features.length === 0) { + return { found: false } + } + + // Pick the first rendered point + const feature = features[0] + const coords = feature.geometry.coordinates + const point = controller.map.project(coords) + + // Get the canvas position on the page + const canvas = controller.map.getCanvas() + const rect = canvas.getBoundingClientRect() + + return { + found: true, + pointId: feature.properties.id, + coords: coords, + x: point.x, + y: point.y, + pageX: rect.left + point.x, + pageY: rect.top + point.y + } + }) + + expect(renderedPoint.found).toBe(true) + + const pointId = renderedPoint.pointId + const initialCoords = renderedPoint.coords + const pointPixel = { + x: renderedPoint.x, + y: renderedPoint.y, + pageX: renderedPoint.pageX, + pageY: renderedPoint.pageY + } + + + const dragOffset = { x: 100, y: 100 } + const startX = pointPixel.pageX + const startY = pointPixel.pageY + const endX = startX + dragOffset.x + const endY = startY + dragOffset.y + + // Perform drag with slower movement + await page.mouse.move(startX, startY) + await page.waitForTimeout(100) + await page.mouse.down() + await page.waitForTimeout(100) + await page.mouse.move(endX, endY, { steps: 20 }) + await page.waitForTimeout(100) + await page.mouse.up() + + // Wait for API call + await page.waitForTimeout(3000) + + // Get the new position + const afterDragData = await getPointsSourceData(page) + const afterDragPoint = afterDragData.features.find(f => f.properties.id === pointId) + const afterDragCoords = afterDragPoint.geometry.coordinates + + + // Reload the page + await page.reload() + await closeOnboardingModal(page) + await waitForLoadingComplete(page) + await page.waitForTimeout(1500) + + // Wait for points to reload + await page.waitForFunction(() => { + const element = document.querySelector('[data-controller*="maps--maplibre"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps--maplibre') + const source = controller?.map?.getSource('points-source') + return source?._data?.features?.length > 0 + }, { timeout: 15000 }) + + // Get point after reload + const afterReloadData = await getPointsSourceData(page) + const afterReloadPoint = afterReloadData.features.find(f => f.properties.id === pointId) + const afterReloadCoords = afterReloadPoint.geometry.coordinates + + + // Verify the position persisted (parse coordinates as numbers) + const reloadLng = parseFloat(afterReloadCoords[0]) + const reloadLat = parseFloat(afterReloadCoords[1]) + const dragLng = parseFloat(afterDragCoords[0]) + const dragLat = parseFloat(afterDragCoords[1]) + const initialLng = parseFloat(initialCoords[0]) + const initialLat = parseFloat(initialCoords[1]) + + // Position after reload should match position after drag (high precision) + expect(reloadLng).toBeCloseTo(dragLng, 5) + expect(reloadLat).toBeCloseTo(dragLat, 5) + + // And it should be different from the initial position (lower precision - just verify it moved) + const lngDiff = Math.abs(reloadLng - initialLng) + const latDiff = Math.abs(reloadLat - initialLat) + const moved = lngDiff > 0.00001 || latDiff > 0.00001 + + expect(moved).toBe(true) + }) + }) }) diff --git a/e2e/v2/map/layers/routes.spec.js b/e2e/v2/map/layers/routes.spec.js index ad705fb9..9d239c1c 100644 --- a/e2e/v2/map/layers/routes.spec.js +++ b/e2e/v2/map/layers/routes.spec.js @@ -124,8 +124,16 @@ test.describe('Routes Layer', () => { expect(routeLayerInfo).toBeTruthy() expect(routeLayerInfo.exists).toBe(true) - expect(routeLayerInfo.isArray).toBe(false) - expect(routeLayerInfo.value).toBe('#f97316') + + // Route color is now a MapLibre expression that supports dynamic colors + // Format: ['case', ['has', 'color'], ['get', 'color'], '#0000ff'] + if (routeLayerInfo.isArray) { + // It's a MapLibre expression, check the default color (last element) + expect(routeLayerInfo.value[routeLayerInfo.value.length - 1]).toBe('#0000ff') + } else { + // Solid color (fallback) + expect(routeLayerInfo.value).toBe('#0000ff') + } }) }) diff --git a/lib/tasks/points_raw_data.rake b/lib/tasks/points_raw_data.rake new file mode 100644 index 00000000..0d5e60f2 --- /dev/null +++ b/lib/tasks/points_raw_data.rake @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +namespace :points do + namespace :raw_data do + desc 'Restore raw_data from archive to database for a specific month' + task :restore, [:user_id, :year, :month] => :environment do |_t, args| + validate_args!(args) + + user_id = args[:user_id].to_i + year = args[:year].to_i + month = args[:month].to_i + + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Restoring raw_data to DATABASE' + puts " User: #{user_id} | Month: #{year}-#{format('%02d', month)}" + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + restorer = Points::RawData::Restorer.new + restorer.restore_to_database(user_id, year, month) + + puts '' + puts '✓ Restoration complete!' + puts '' + puts "Points in #{year}-#{month} now have raw_data in database." + puts 'Run VACUUM ANALYZE points; to update statistics.' + end + + desc 'Restore raw_data to memory/cache temporarily (for data migrations)' + task :restore_temporary, [:user_id, :year, :month] => :environment do |_t, args| + validate_args!(args) + + user_id = args[:user_id].to_i + year = args[:year].to_i + month = args[:month].to_i + + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Loading raw_data into CACHE (temporary)' + puts " User: #{user_id} | Month: #{year}-#{format('%02d', month)}" + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + puts 'Data will be available for 1 hour via Point.raw_data_with_archive accessor' + puts '' + + restorer = Points::RawData::Restorer.new + restorer.restore_to_memory(user_id, year, month) + + puts '' + puts '✓ Cache loaded successfully!' + puts '' + puts 'You can now run your data migration.' + puts 'Example:' + puts " rails runner \"Point.where(user_id: #{user_id}, timestamp_year: #{year}, timestamp_month: #{month}).find_each { |p| p.fix_coordinates_from_raw_data }\"" + puts '' + puts 'Cache will expire in 1 hour automatically.' + end + + desc 'Restore all archived raw_data for a user' + task :restore_all, [:user_id] => :environment do |_t, args| + raise 'Usage: rake points:raw_data:restore_all[user_id]' unless args[:user_id] + + user_id = args[:user_id].to_i + user = User.find(user_id) + + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Restoring ALL archives for user' + puts " #{user.email} (ID: #{user_id})" + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + archives = Points::RawDataArchive.where(user_id: user_id) + .select(:year, :month) + .distinct + .order(:year, :month) + + puts "Found #{archives.count} months to restore" + puts '' + + archives.each_with_index do |archive, idx| + puts "[#{idx + 1}/#{archives.count}] Restoring #{archive.year}-#{format('%02d', archive.month)}..." + + restorer = Points::RawData::Restorer.new + restorer.restore_to_database(user_id, archive.year, archive.month) + end + + puts '' + puts "✓ All archives restored for user #{user_id}!" + end + + desc 'Show archive statistics' + task status: :environment do + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Points raw_data Archive Statistics' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + total_archives = Points::RawDataArchive.count + verified_archives = Points::RawDataArchive.where.not(verified_at: nil).count + unverified_archives = total_archives - verified_archives + + total_points = Point.count + archived_points = Point.where(raw_data_archived: true).count + cleared_points = Point.where(raw_data_archived: true, raw_data: {}).count + archived_not_cleared = archived_points - cleared_points + + percentage = total_points.positive? ? (archived_points.to_f / total_points * 100).round(2) : 0 + + puts "Archives: #{total_archives} (#{verified_archives} verified, #{unverified_archives} unverified)" + puts "Points archived: #{archived_points} / #{total_points} (#{percentage}%)" + puts "Points cleared: #{cleared_points}" + puts "Archived but not cleared: #{archived_not_cleared}" + puts '' + + # Storage size via ActiveStorage + total_blob_size = ActiveStorage::Blob + .joins('INNER JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id') + .where("active_storage_attachments.record_type = 'Points::RawDataArchive'") + .sum(:byte_size) + + puts "Storage used: #{ActiveSupport::NumberHelper.number_to_human_size(total_blob_size)}" + puts '' + + # Recent activity + recent = Points::RawDataArchive.where('archived_at > ?', 7.days.ago).count + puts "Archives created last 7 days: #{recent}" + puts '' + + # Top users + puts 'Top 10 users by archive count:' + puts '─────────────────────────────────────────────────' + + Points::RawDataArchive.group(:user_id) + .select('user_id, COUNT(*) as archive_count, SUM(point_count) as total_points') + .order('archive_count DESC') + .limit(10) + .each_with_index do |stat, idx| + user = User.find(stat.user_id) + puts "#{idx + 1}. #{user.email.ljust(30)} #{stat.archive_count.to_s.rjust(3)} archives, #{stat.total_points.to_s.rjust(8)} points" + end + + puts '' + end + + desc 'Verify archive integrity (all unverified archives, or specific month with args)' + task :verify, [:user_id, :year, :month] => :environment do |_t, args| + verifier = Points::RawData::Verifier.new + + if args[:user_id] && args[:year] && args[:month] + # Verify specific month + user_id = args[:user_id].to_i + year = args[:year].to_i + month = args[:month].to_i + + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Verifying Archives' + puts " User: #{user_id} | Month: #{year}-#{format('%02d', month)}" + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + verifier.verify_month(user_id, year, month) + else + # Verify all unverified archives + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Verifying All Unverified Archives' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + stats = verifier.call + + puts '' + puts "Verified: #{stats[:verified]}" + puts "Failed: #{stats[:failed]}" + end + + puts '' + puts '✓ Verification complete!' + end + + desc 'Clear raw_data for verified archives (all verified, or specific month with args)' + task :clear_verified, [:user_id, :year, :month] => :environment do |_t, args| + clearer = Points::RawData::Clearer.new + + if args[:user_id] && args[:year] && args[:month] + # Clear specific month + user_id = args[:user_id].to_i + year = args[:year].to_i + month = args[:month].to_i + + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Clearing Verified Archives' + puts " User: #{user_id} | Month: #{year}-#{format('%02d', month)}" + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + clearer.clear_month(user_id, year, month) + else + # Clear all verified archives + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Clearing All Verified Archives' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + stats = clearer.call + + puts '' + puts "Points cleared: #{stats[:cleared]}" + end + + puts '' + puts '✓ Clearing complete!' + puts '' + puts 'Run VACUUM ANALYZE points; to reclaim space and update statistics.' + end + + desc 'Archive raw_data for old data (2+ months old, does NOT clear yet)' + task archive: :environment do + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Archiving Raw Data (2+ months old data)' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + puts 'This will archive points.raw_data for months 2+ months old.' + puts 'Raw data will NOT be cleared yet - use verify and clear_verified tasks.' + puts 'This is safe to run multiple times (idempotent).' + puts '' + + stats = Points::RawData::Archiver.new.call + + puts '' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Archival Complete' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + puts "Months processed: #{stats[:processed]}" + puts "Points archived: #{stats[:archived]}" + puts "Failures: #{stats[:failed]}" + puts '' + + return unless stats[:archived].positive? + + puts 'Next steps:' + puts '1. Verify archives: rake points:raw_data:verify' + puts '2. Clear verified data: rake points:raw_data:clear_verified' + puts '3. Check stats: rake points:raw_data:status' + end + + desc 'Full workflow: archive + verify + clear (for automated use)' + task archive_full: :environment do + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' Full Archive Workflow' + puts ' (Archive → Verify → Clear)' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + + # Step 1: Archive + puts '▸ Step 1/3: Archiving...' + archiver_stats = Points::RawData::Archiver.new.call + puts " ✓ Archived #{archiver_stats[:archived]} points" + puts '' + + # Step 2: Verify + puts '▸ Step 2/3: Verifying...' + verifier_stats = Points::RawData::Verifier.new.call + puts " ✓ Verified #{verifier_stats[:verified]} archives" + if verifier_stats[:failed].positive? + puts " ✗ Failed to verify #{verifier_stats[:failed]} archives" + puts '' + puts '⚠ Some archives failed verification. Data NOT cleared for safety.' + puts 'Please investigate failed archives before running clear_verified.' + exit 1 + end + puts '' + + # Step 3: Clear + puts '▸ Step 3/3: Clearing verified data...' + clearer_stats = Points::RawData::Clearer.new.call + puts " ✓ Cleared #{clearer_stats[:cleared]} points" + puts '' + + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts ' ✓ Full Archive Workflow Complete!' + puts '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━' + puts '' + puts 'Run VACUUM ANALYZE points; to reclaim space.' + end + + # Alias for backward compatibility + task initial_archive: :archive + end +end + +def validate_args!(args) + return if args[:user_id] && args[:year] && args[:month] + + raise 'Usage: rake points:raw_data:TASK[user_id,year,month]' +end diff --git a/lib/timestamps.rb b/lib/timestamps.rb index 2154a3ef..59273d59 100644 --- a/lib/timestamps.rb +++ b/lib/timestamps.rb @@ -2,17 +2,20 @@ module Timestamps def self.parse_timestamp(timestamp) - begin - # if the timestamp is in ISO 8601 format, try to parse it - DateTime.parse(timestamp).to_time.to_i - rescue + min_timestamp = Time.zone.parse('1970-01-01').to_i + max_timestamp = Time.zone.parse('2100-01-01').to_i + + parsed = DateTime.parse(timestamp).to_time.to_i + + parsed.clamp(min_timestamp, max_timestamp) + rescue StandardError + result = if timestamp.to_s.length > 10 - # If the timestamp is in milliseconds, convert to seconds timestamp.to_i / 1000 else - # If the timestamp is in seconds, return it without change timestamp.to_i end - end + + result.clamp(min_timestamp, max_timestamp) end end diff --git a/spec/controllers/concerns/safe_timestamp_parser_spec.rb b/spec/controllers/concerns/safe_timestamp_parser_spec.rb new file mode 100644 index 00000000..b5248468 --- /dev/null +++ b/spec/controllers/concerns/safe_timestamp_parser_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SafeTimestampParser, type: :controller do + include ActiveSupport::Testing::TimeHelpers + + controller(ActionController::Base) do + include SafeTimestampParser + + def index + render plain: safe_timestamp(params[:date]).to_s + end + end + + before do + routes.draw { get 'index' => 'anonymous#index' } + end + + describe '#safe_timestamp' do + context 'with valid dates within range' do + it 'returns correct timestamp for 2020-01-01' do + get :index, params: { date: '2020-01-01' } + expected = Time.zone.parse('2020-01-01').to_i + expect(response.body).to eq(expected.to_s) + end + + it 'returns correct timestamp for 1980-06-15' do + get :index, params: { date: '1980-06-15' } + expected = Time.zone.parse('1980-06-15').to_i + expect(response.body).to eq(expected.to_s) + end + end + + context 'with dates before valid range' do + it 'clamps year 1000 to minimum timestamp (1970-01-01)' do + get :index, params: { date: '1000-01-30' } + min_timestamp = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(min_timestamp.to_s) + end + + it 'clamps year 1900 to minimum timestamp (1970-01-01)' do + get :index, params: { date: '1900-12-25' } + min_timestamp = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(min_timestamp.to_s) + end + + it 'clamps year 1969 to minimum timestamp (1970-01-01)' do + get :index, params: { date: '1969-07-20' } + min_timestamp = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(min_timestamp.to_s) + end + end + + context 'with dates after valid range' do + it 'clamps year 2150 to maximum timestamp (2100-01-01)' do + get :index, params: { date: '2150-01-01' } + max_timestamp = Time.zone.parse('2100-01-01').to_i + expect(response.body).to eq(max_timestamp.to_s) + end + + it 'clamps year 3000 to maximum timestamp (2100-01-01)' do + get :index, params: { date: '3000-12-31' } + max_timestamp = Time.zone.parse('2100-01-01').to_i + expect(response.body).to eq(max_timestamp.to_s) + end + end + + context 'with invalid date strings' do + it 'returns current time for unparseable date' do + travel_to Time.zone.parse('2023-06-15 12:00:00') do + get :index, params: { date: 'not-a-date' } + expected = Time.zone.now.to_i + expect(response.body).to eq(expected.to_s) + end + end + + it 'returns current time for empty string' do + travel_to Time.zone.parse('2023-06-15 12:00:00') do + get :index, params: { date: '' } + expected = Time.zone.now.to_i + expect(response.body).to eq(expected.to_s) + end + end + end + + context 'edge cases' do + it 'handles Unix epoch exactly (1970-01-01)' do + get :index, params: { date: '1970-01-01' } + expected = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(expected.to_s) + end + + it 'handles maximum date exactly (2100-01-01)' do + get :index, params: { date: '2100-01-01' } + expected = Time.zone.parse('2100-01-01').to_i + expect(response.body).to eq(expected.to_s) + end + end + end +end diff --git a/spec/factories/points_raw_data_archives.rb b/spec/factories/points_raw_data_archives.rb new file mode 100644 index 00000000..12f576c0 --- /dev/null +++ b/spec/factories/points_raw_data_archives.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :points_raw_data_archive, class: 'Points::RawDataArchive' do + user + year { 2024 } + month { 6 } + chunk_number { 1 } + point_count { 100 } + point_ids_checksum { Digest::SHA256.hexdigest('1,2,3') } + archived_at { Time.current } + metadata { { format_version: 1, compression: 'gzip' } } + + after(:build) do |archive| + # Attach a test file + archive.file.attach( + io: StringIO.new(gzip_test_data), + filename: archive.filename, + content_type: 'application/gzip' + ) + end + end +end + +def gzip_test_data + io = StringIO.new + gz = Zlib::GzipWriter.new(io) + gz.puts({ id: 1, raw_data: { lon: 13.4, lat: 52.5 } }.to_json) + gz.puts({ id: 2, raw_data: { lon: 13.5, lat: 52.6 } }.to_json) + gz.close + io.string +end diff --git a/spec/fixtures/files/geojson/export_same_points.json b/spec/fixtures/files/geojson/export_same_points.json index 6d1559c3..4f21d3cf 100644 --- a/spec/fixtures/files/geojson/export_same_points.json +++ b/spec/fixtures/files/geojson/export_same_points.json @@ -1 +1 @@ -{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459201,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459202,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459203,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459204,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459205,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459206,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459207,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459208,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459209,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null}}]} +{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459200,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459201,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459202,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459203,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459204,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459205,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459206,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459207,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459208,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}},{"type":"Feature","geometry":{"type":"Point","coordinates":[37.6173,55.755826]},"properties":{"battery_status":"unplugged","ping":"MyString","battery":1,"tracker_id":"MyString","topic":"MyString","altitude":1,"longitude":"37.6173","velocity":"0","trigger":"background_event","bssid":"MyString","ssid":"MyString","connection":"wifi","vertical_accuracy":1,"accuracy":1,"timestamp":1609459209,"latitude":"55.755826","mode":1,"inrids":[],"in_regions":[],"city":null,"country":null,"geodata":{},"course":null,"course_accuracy":null,"external_track_id":null,"track_id":null,"country_name":null,"raw_data_archived":false,"raw_data_archive_id":null}}]} diff --git a/spec/fixtures/files/kml/points_with_timestamps.kmz b/spec/fixtures/files/kml/points_with_timestamps.kmz new file mode 100644 index 0000000000000000000000000000000000000000..eb87467b3c500919ba3e1cc163784b0e8daaaabb GIT binary patch literal 435 zcmWIWW@Zs#U|`^2IGorSebC~U0zV@IgF7<=13QBZLrQ+KUUqIyXb2|*a~+FCCJ2{S za5FHnd|jN@L&`>mTrmvlHR$ax&M^=|lwopmR|3Y3NFI@Z0|vaczZ zM2QKnr`*l%U&Cc#W#G)y#lo8Q{6h}UN);RcyXaAO8RND$=v4^ zTJvgmFV2}V@$bVm&(FDEKOyj(x#VTvRl{eQJu~wk-MyftEs^Nzmf?1K)+STMk9!Ji zrap`Nvv{Vzu-+o6=f8dk7-{Gmosv*m-L&kdt(JtcrmBp-?EHh>Gc@)o|Lr=l&hbos zXGEO9=Z=Vf>`8UJNrm1=c5^2=27UV;|KvyZ-k&?ZEB#~$@MdI^W5yMt62OpVU;qXk g!;(f23m(3#knlwd+W>D?Hjpw#AoK;&0U(_W0Kr$I82|tP literal 0 HcmV?d00001 diff --git a/spec/jobs/points/raw_data/archive_job_spec.rb b/spec/jobs/points/raw_data/archive_job_spec.rb new file mode 100644 index 00000000..70e1eb7b --- /dev/null +++ b/spec/jobs/points/raw_data/archive_job_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawData::ArchiveJob, type: :job do + describe '#perform' do + let(:archiver) { instance_double(Points::RawData::Archiver) } + + before do + # Enable archival for tests + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('ARCHIVE_RAW_DATA').and_return('true') + + allow(Points::RawData::Archiver).to receive(:new).and_return(archiver) + allow(archiver).to receive(:call).and_return({ processed: 5, archived: 100, failed: 0 }) + end + + it 'calls the archiver service' do + expect(archiver).to receive(:call) + + described_class.perform_now + end + + context 'when archiver raises an error' do + let(:error) { StandardError.new('Archive failed') } + + before do + allow(archiver).to receive(:call).and_raise(error) + end + + it 're-raises the error' do + expect do + described_class.perform_now + end.to raise_error(StandardError, 'Archive failed') + end + + it 'reports the error before re-raising' do + expect(ExceptionReporter).to receive(:call).with(error, 'Points raw data archival job failed') + + expect do + described_class.perform_now + end.to raise_error(StandardError) + end + end + end +end diff --git a/spec/jobs/points/raw_data/re_archive_month_job_spec.rb b/spec/jobs/points/raw_data/re_archive_month_job_spec.rb new file mode 100644 index 00000000..277caf6e --- /dev/null +++ b/spec/jobs/points/raw_data/re_archive_month_job_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawData::ReArchiveMonthJob, type: :job do + describe '#perform' do + let(:archiver) { instance_double(Points::RawData::Archiver) } + let(:user_id) { 123 } + let(:year) { 2024 } + let(:month) { 6 } + + before do + allow(Points::RawData::Archiver).to receive(:new).and_return(archiver) + end + + it 'calls archive_specific_month with correct parameters' do + expect(archiver).to receive(:archive_specific_month).with(user_id, year, month) + + described_class.perform_now(user_id, year, month) + end + + context 'when re-archival fails' do + before do + allow(archiver).to receive(:archive_specific_month).and_raise(StandardError, 'Re-archive failed') + end + + it 're-raises the error' do + expect do + described_class.perform_now(user_id, year, month) + end.to raise_error(StandardError, 'Re-archive failed') + end + end + end +end diff --git a/spec/models/concerns/archivable_spec.rb b/spec/models/concerns/archivable_spec.rb new file mode 100644 index 00000000..53f7a56c --- /dev/null +++ b/spec/models/concerns/archivable_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Archivable, type: :model do + let(:user) { create(:user) } + let(:point) { create(:point, user: user, raw_data: { lon: 13.4, lat: 52.5 }) } + + describe 'associations and scopes' do + it { expect(point).to belong_to(:raw_data_archive).optional } + + describe 'scopes' do + let!(:archived_point) { create(:point, user: user, raw_data_archived: true) } + let!(:not_archived_point) { create(:point, user: user, raw_data_archived: false) } + + it '.archived returns archived points' do + expect(Point.archived).to include(archived_point) + expect(Point.archived).not_to include(not_archived_point) + end + + it '.not_archived returns non-archived points' do + expect(Point.not_archived).to include(not_archived_point) + expect(Point.not_archived).not_to include(archived_point) + end + end + end + + describe '#raw_data_with_archive' do + context 'when raw_data is present in database' do + it 'returns raw_data from database' do + expect(point.raw_data_with_archive).to eq({ 'lon' => 13.4, 'lat' => 52.5 }) + end + end + + context 'when raw_data is archived' do + let(:archive) { create(:points_raw_data_archive, user: user) } + let(:archived_point) do + create(:point, user: user, raw_data: nil, raw_data_archived: true, raw_data_archive: archive) + end + + before do + # Mock archive file content with this specific point + compressed_data = gzip_data([ + { id: archived_point.id, raw_data: { lon: 14.0, lat: 53.0 } } + ]) + allow(archive.file.blob).to receive(:download).and_return(compressed_data) + end + + it 'fetches raw_data from archive' do + result = archived_point.raw_data_with_archive + expect(result).to eq({ 'id' => archived_point.id, 'raw_data' => { 'lon' => 14.0, 'lat' => 53.0 } }['raw_data']) + end + end + + context 'when raw_data is archived but point not in archive' do + let(:archive) { create(:points_raw_data_archive, user: user) } + let(:archived_point) do + create(:point, user: user, raw_data: nil, raw_data_archived: true, raw_data_archive: archive) + end + + before do + # Mock archive file with different point + compressed_data = gzip_data([ + { id: 999, raw_data: { lon: 14.0, lat: 53.0 } } + ]) + allow(archive.file.blob).to receive(:download).and_return(compressed_data) + end + + it 'returns empty hash' do + expect(archived_point.raw_data_with_archive).to eq({}) + end + end + end + + describe '#restore_raw_data!' do + let(:archive) { create(:points_raw_data_archive, user: user) } + let(:archived_point) do + create(:point, user: user, raw_data: nil, raw_data_archived: true, raw_data_archive: archive) + end + + it 'restores raw_data to database and clears archive flags' do + new_data = { lon: 15.0, lat: 54.0 } + archived_point.restore_raw_data!(new_data) + + archived_point.reload + expect(archived_point.raw_data).to eq(new_data.stringify_keys) + expect(archived_point.raw_data_archived).to be false + expect(archived_point.raw_data_archive_id).to be_nil + end + end + + describe 'temporary cache' do + let(:june_point) { create(:point, user: user, timestamp: Time.new(2024, 6, 15).to_i) } + + it 'checks temporary restore cache with correct key format' do + cache_key = "raw_data:temp:#{user.id}:2024:6:#{june_point.id}" + cached_data = { lon: 16.0, lat: 55.0 } + + Rails.cache.write(cache_key, cached_data, expires_in: 1.hour) + + # Access through send since check_temporary_restore_cache is private + result = june_point.send(:check_temporary_restore_cache) + expect(result).to eq(cached_data) + end + end + + def gzip_data(points_array) + io = StringIO.new + gz = Zlib::GzipWriter.new(io) + points_array.each do |point_data| + gz.puts(point_data.to_json) + end + gz.close + io.string + end +end diff --git a/spec/models/points/raw_data_archive_spec.rb b/spec/models/points/raw_data_archive_spec.rb new file mode 100644 index 00000000..0ffa54d4 --- /dev/null +++ b/spec/models/points/raw_data_archive_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawDataArchive, type: :model do + let(:user) { create(:user) } + subject(:archive) { build(:points_raw_data_archive, user: user) } + + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:points).dependent(:nullify) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:year) } + it { is_expected.to validate_presence_of(:month) } + it { is_expected.to validate_presence_of(:chunk_number) } + it { is_expected.to validate_presence_of(:point_count) } + it { is_expected.to validate_presence_of(:point_ids_checksum) } + + it { is_expected.to validate_numericality_of(:year).is_greater_than(1970).is_less_than(2100) } + it { is_expected.to validate_numericality_of(:month).is_greater_than_or_equal_to(1).is_less_than_or_equal_to(12) } + it { is_expected.to validate_numericality_of(:chunk_number).is_greater_than(0) } + + end + + describe 'scopes' do + let!(:recent_archive) { create(:points_raw_data_archive, user: user, year: 2024, month: 5, archived_at: 1.day.ago) } + let!(:old_archive) { create(:points_raw_data_archive, user: user, year: 2023, month: 5, archived_at: 2.years.ago) } + + describe '.recent' do + it 'returns archives from last 30 days' do + expect(described_class.recent).to include(recent_archive) + expect(described_class.recent).not_to include(old_archive) + end + end + + describe '.old' do + it 'returns archives older than 1 year' do + expect(described_class.old).to include(old_archive) + expect(described_class.old).not_to include(recent_archive) + end + end + + describe '.for_month' do + let!(:june_archive) { create(:points_raw_data_archive, user: user, year: 2024, month: 6, chunk_number: 1) } + let!(:june_archive_2) { create(:points_raw_data_archive, user: user, year: 2024, month: 6, chunk_number: 2) } + let!(:july_archive) { create(:points_raw_data_archive, user: user, year: 2024, month: 7, chunk_number: 1) } + + it 'returns archives for specific month ordered by chunk number' do + result = described_class.for_month(user.id, 2024, 6) + expect(result.map(&:chunk_number)).to eq([1, 2]) + expect(result).to include(june_archive, june_archive_2) + expect(result).not_to include(july_archive) + end + end + end + + describe '#month_display' do + it 'returns formatted month and year' do + archive = build(:points_raw_data_archive, year: 2024, month: 6) + expect(archive.month_display).to eq('June 2024') + end + end + + describe '#filename' do + it 'generates correct filename with directory structure' do + archive = build(:points_raw_data_archive, user_id: 123, year: 2024, month: 6, chunk_number: 5) + expect(archive.filename).to eq('raw_data_archives/123/2024/06/005.jsonl.gz') + end + end + + describe '#size_mb' do + it 'returns 0 when no file attached' do + archive = build(:points_raw_data_archive) + expect(archive.size_mb).to eq(0) + end + + it 'returns size in MB when file is attached' do + archive = create(:points_raw_data_archive, user: user) + # Mock file with 2MB size + allow(archive.file.blob).to receive(:byte_size).and_return(2 * 1024 * 1024) + expect(archive.size_mb).to eq(2.0) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c11017e6..7eac9400 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -199,8 +199,8 @@ RSpec.describe User, type: :model do describe '#total_distance' do subject { user.total_distance } - let!(:stat1) { create(:stat, user:, distance: 10_000) } - let!(:stat2) { create(:stat, user:, distance: 20_000) } + let!(:stat1) { create(:stat, user:, year: 2020, month: 10, distance: 10_000) } + let!(:stat2) { create(:stat, user:, year: 2020, month: 11, distance: 20_000) } it 'returns sum of distances' do expect(subject).to eq(30) # 30 km @@ -341,14 +341,16 @@ RSpec.describe User, type: :model do describe '.from_omniauth' do let(:auth_hash) do - OmniAuth::AuthHash.new({ - provider: 'github', - uid: '123545', - info: { - email: email, - name: 'Test User' + OmniAuth::AuthHash.new( + { + provider: 'github', + uid: '123545', + info: { + email: email, + name: 'Test User' + } } - }) + ) end context 'when user exists with the same email' do @@ -394,14 +396,16 @@ RSpec.describe User, type: :model do context 'when OAuth provider is Google' do let(:email) { 'google@example.com' } let(:auth_hash) do - OmniAuth::AuthHash.new({ - provider: 'google_oauth2', - uid: '123545', - info: { - email: email, - name: 'Google User' + OmniAuth::AuthHash.new( + { + provider: 'google_oauth2', + uid: '123545', + info: { + email: email, + name: 'Google User' + } } - }) + ) end it 'creates a user from Google OAuth data' do diff --git a/spec/requests/api/v1/stats_spec.rb b/spec/requests/api/v1/stats_spec.rb index 314c375e..a7765d85 100644 --- a/spec/requests/api/v1/stats_spec.rb +++ b/spec/requests/api/v1/stats_spec.rb @@ -5,8 +5,8 @@ require 'rails_helper' RSpec.describe 'Api::V1::Stats', type: :request do describe 'GET /index' do let(:user) { create(:user) } - let(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } - let(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } + let(:stats_in_2020) { (1..12).map { |month| create(:stat, year: 2020, month:, user:) } } + let(:stats_in_2021) { (1..12).map { |month| create(:stat, year: 2021, month:, user:) } } let(:points_in_2020) do (1..85).map do |i| create(:point, :with_geodata, @@ -50,17 +50,17 @@ RSpec.describe 'Api::V1::Stats', type: :request do totalCitiesVisited: 1, monthlyDistanceKm: { january: 1, - february: 0, - march: 0, - april: 0, - may: 0, - june: 0, - july: 0, - august: 0, - september: 0, - october: 0, - november: 0, - december: 0 + february: 1, + march: 1, + april: 1, + may: 1, + june: 1, + july: 1, + august: 1, + september: 1, + october: 1, + november: 1, + december: 1 } }, { @@ -70,17 +70,17 @@ RSpec.describe 'Api::V1::Stats', type: :request do totalCitiesVisited: 1, monthlyDistanceKm: { january: 1, - february: 0, - march: 0, - april: 0, - may: 0, - june: 0, - july: 0, - august: 0, - september: 0, - october: 0, - november: 0, - december: 0 + february: 1, + march: 1, + april: 1, + may: 1, + june: 1, + july: 1, + august: 1, + september: 1, + october: 1, + november: 1, + december: 1 } } ] @@ -100,4 +100,3 @@ RSpec.describe 'Api::V1::Stats', type: :request do end end end - diff --git a/spec/serializers/point_serializer_spec.rb b/spec/serializers/point_serializer_spec.rb index c07e2a90..4042b2aa 100644 --- a/spec/serializers/point_serializer_spec.rb +++ b/spec/serializers/point_serializer_spec.rb @@ -35,7 +35,9 @@ RSpec.describe PointSerializer do 'course_accuracy' => point.course_accuracy, 'external_track_id' => point.external_track_id, 'track_id' => point.track_id, - 'country_name' => point.read_attribute(:country_name) + 'country_name' => point.read_attribute(:country_name), + 'raw_data_archived' => point.raw_data_archived, + 'raw_data_archive_id' => point.raw_data_archive_id } end diff --git a/spec/serializers/stats_serializer_spec.rb b/spec/serializers/stats_serializer_spec.rb index 7198f48f..af394752 100644 --- a/spec/serializers/stats_serializer_spec.rb +++ b/spec/serializers/stats_serializer_spec.rb @@ -26,8 +26,8 @@ RSpec.describe StatsSerializer do end context 'when the user has stats' do - let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } - let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } + let!(:stats_in_2020) { (1..12).map { |month| create(:stat, year: 2020, month:, user:) } } + let!(:stats_in_2021) { (1..12).map { |month| create(:stat, year: 2021, month:, user:) } } let!(:points_in_2020) do (1..85).map do |i| create(:point, :with_geodata, @@ -63,17 +63,17 @@ RSpec.describe StatsSerializer do "totalCitiesVisited": 1, "monthlyDistanceKm": { "january": 1, - "february": 0, - "march": 0, - "april": 0, - "may": 0, - "june": 0, - "july": 0, - "august": 0, - "september": 0, - "october": 0, - "november": 0, - "december": 0 + "february": 1, + "march": 1, + "april": 1, + "may": 1, + "june": 1, + "july": 1, + "august": 1, + "september": 1, + "october": 1, + "november": 1, + "december": 1 } }, { @@ -83,17 +83,17 @@ RSpec.describe StatsSerializer do "totalCitiesVisited": 1, "monthlyDistanceKm": { "january": 1, - "february": 0, - "march": 0, - "april": 0, - "may": 0, - "june": 0, - "july": 0, - "august": 0, - "september": 0, - "october": 0, - "november": 0, - "december": 0 + "february": 1, + "march": 1, + "april": 1, + "may": 1, + "june": 1, + "july": 1, + "august": 1, + "september": 1, + "october": 1, + "november": 1, + "december": 1 } } ] diff --git a/spec/services/kml/importer_spec.rb b/spec/services/kml/importer_spec.rb index afdcbb35..22907cfd 100644 --- a/spec/services/kml/importer_spec.rb +++ b/spec/services/kml/importer_spec.rb @@ -142,6 +142,31 @@ RSpec.describe Kml::Importer do end end + context 'when importing KMZ file (compressed KML)' do + let(:file_path) { Rails.root.join('spec/fixtures/files/kml/points_with_timestamps.kmz').to_s } + + it 'extracts and processes KML from KMZ archive' do + expect { parser }.to change(Point, :count).by(3) + end + + it 'creates points with correct data from extracted KML' do + parser + + point = user.points.order(:timestamp).first + + expect(point.lat).to eq(37.4220) + expect(point.lon).to eq(-122.0841) + expect(point.altitude).to eq(10) + expect(point.timestamp).to eq(Time.zone.parse('2024-01-15T12:00:00Z').to_i) + end + + it 'broadcasts importing progress' do + expect_any_instance_of(Imports::Broadcaster).to receive(:broadcast_import_progress).at_least(1).time + + parser + end + end + context 'when import fails' do let(:file_path) { Rails.root.join('spec/fixtures/files/kml/points_with_timestamps.kml').to_s } diff --git a/spec/services/points/raw_data/archiver_spec.rb b/spec/services/points/raw_data/archiver_spec.rb new file mode 100644 index 00000000..259056de --- /dev/null +++ b/spec/services/points/raw_data/archiver_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawData::Archiver do + let(:user) { create(:user) } + let(:archiver) { described_class.new } + + before do + allow(PointsChannel).to receive(:broadcast_to) + end + + describe '#call' do + context 'when archival is disabled' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('ARCHIVE_RAW_DATA').and_return('false') + end + + it 'returns early without processing' do + result = archiver.call + + expect(result).to eq({ processed: 0, archived: 0, failed: 0 }) + end + end + + context 'when archival is enabled' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('ARCHIVE_RAW_DATA').and_return('true') + end + + let!(:old_points) do + # Create points 3 months ago (definitely older than 2 month lag) + old_date = 3.months.ago.beginning_of_month + create_list(:point, 5, user: user, + timestamp: old_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + end + + it 'archives old points' do + expect { archiver.call }.to change(Points::RawDataArchive, :count).by(1) + end + + it 'marks points as archived' do + archiver.call + + expect(Point.where(raw_data_archived: true).count).to eq(5) + end + + it 'keeps raw_data intact (does not clear yet)' do + archiver.call + Point.where(user: user).find_each do |point| + expect(point.raw_data).to eq({ 'lon' => 13.4, 'lat' => 52.5 }) + end + end + + it 'returns correct stats' do + result = archiver.call + + expect(result[:processed]).to eq(1) + expect(result[:archived]).to eq(5) + expect(result[:failed]).to eq(0) + end + end + + context 'with points from multiple months' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('ARCHIVE_RAW_DATA').and_return('true') + end + + let!(:june_points) do + june_date = 4.months.ago.beginning_of_month + create_list(:point, 3, user: user, + timestamp: june_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + end + + let!(:july_points) do + july_date = 3.months.ago.beginning_of_month + create_list(:point, 2, user: user, + timestamp: july_date.to_i, + raw_data: { lon: 14.0, lat: 53.0 }) + end + + it 'creates separate archives for each month' do + expect { archiver.call }.to change(Points::RawDataArchive, :count).by(2) + end + + it 'archives all points' do + archiver.call + expect(Point.where(raw_data_archived: true).count).to eq(5) + end + end + end + + describe '#archive_specific_month' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('ARCHIVE_RAW_DATA').and_return('true') + end + + let(:test_date) { 3.months.ago.beginning_of_month.utc } + let!(:june_points) do + create_list(:point, 3, user: user, + timestamp: test_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + end + + it 'archives specific month' do + expect do + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + end.to change(Points::RawDataArchive, :count).by(1) + end + + it 'creates archive with correct metadata' do + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + + archive = user.raw_data_archives.last + + expect(archive.user_id).to eq(user.id) + expect(archive.year).to eq(test_date.year) + expect(archive.month).to eq(test_date.month) + expect(archive.point_count).to eq(3) + expect(archive.chunk_number).to eq(1) + end + + it 'attaches compressed file' do + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + + archive = user.raw_data_archives.last + expect(archive.file).to be_attached + expect(archive.file.key).to match(%r{raw_data_archives/\d+/\d{4}/\d{2}/001\.jsonl\.gz}) + end + end + + describe 'append-only architecture' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('ARCHIVE_RAW_DATA').and_return('true') + end + + # Use UTC from the start to avoid timezone issues + let(:test_date_utc) { 3.months.ago.utc.beginning_of_month } + let!(:june_points_batch1) do + create_list(:point, 2, user: user, + timestamp: test_date_utc.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + end + + it 'creates additional chunks for same month' do + # First archival + archiver.archive_specific_month(user.id, test_date_utc.year, test_date_utc.month) + expect(Points::RawDataArchive.for_month(user.id, test_date_utc.year, test_date_utc.month).count).to eq(1) + expect(Points::RawDataArchive.last.chunk_number).to eq(1) + + # Verify first batch is archived + june_points_batch1.each(&:reload) + expect(june_points_batch1.all?(&:raw_data_archived)).to be true + + # Add more points for same month (retrospective import) + # Use unique timestamps to avoid uniqueness validation errors + mid_month = test_date_utc + 15.days + june_points_batch2 = [ + create(:point, user: user, timestamp: mid_month.to_i, raw_data: { lon: 14.0, lat: 53.0 }), + create(:point, user: user, timestamp: (mid_month + 1.hour).to_i, raw_data: { lon: 14.0, lat: 53.0 }) + ] + + # Verify second batch exists and is not archived + expect(june_points_batch2.all? { |p| !p.raw_data_archived }).to be true + + # Second archival should create chunk 2 + archiver.archive_specific_month(user.id, test_date_utc.year, test_date_utc.month) + expect(Points::RawDataArchive.for_month(user.id, test_date_utc.year, test_date_utc.month).count).to eq(2) + expect(Points::RawDataArchive.last.chunk_number).to eq(2) + end + end + + describe 'advisory locking' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('ARCHIVE_RAW_DATA').and_return('true') + end + + let!(:june_points) do + old_date = 3.months.ago.beginning_of_month + create_list(:point, 2, user: user, + timestamp: old_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + end + + it 'prevents duplicate processing with advisory locks' do + # Simulate lock couldn't be acquired (returns nil/false) + allow(ActiveRecord::Base).to receive(:with_advisory_lock).and_return(false) + + result = archiver.call + expect(result[:processed]).to eq(0) + expect(result[:failed]).to eq(0) + end + end +end diff --git a/spec/services/points/raw_data/chunk_compressor_spec.rb b/spec/services/points/raw_data/chunk_compressor_spec.rb new file mode 100644 index 00000000..c8d66983 --- /dev/null +++ b/spec/services/points/raw_data/chunk_compressor_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawData::ChunkCompressor do + let(:user) { create(:user) } + + before do + # Stub broadcasting to avoid ActionCable issues in tests + allow(PointsChannel).to receive(:broadcast_to) + end + let(:points) do + [ + create(:point, user: user, raw_data: { lon: 13.4, lat: 52.5 }), + create(:point, user: user, raw_data: { lon: 13.5, lat: 52.6 }), + create(:point, user: user, raw_data: { lon: 13.6, lat: 52.7 }) + ] + end + let(:compressor) { described_class.new(Point.where(id: points.map(&:id))) } + + describe '#compress' do + it 'returns compressed gzip data' do + result = compressor.compress + expect(result).to be_a(String) + expect(result.encoding.name).to eq('ASCII-8BIT') + end + + it 'compresses points as JSONL format' do + compressed = compressor.compress + + # Decompress and verify format + io = StringIO.new(compressed) + gz = Zlib::GzipReader.new(io) + lines = gz.readlines + gz.close + + expect(lines.count).to eq(3) + + # Each line should be valid JSON + lines.each_with_index do |line, index| + data = JSON.parse(line) + expect(data).to have_key('id') + expect(data).to have_key('raw_data') + expect(data['id']).to eq(points[index].id) + end + end + + it 'includes point ID and raw_data in each line' do + compressed = compressor.compress + + io = StringIO.new(compressed) + gz = Zlib::GzipReader.new(io) + first_line = gz.readline + gz.close + + data = JSON.parse(first_line) + expect(data['id']).to eq(points.first.id) + expect(data['raw_data']).to eq({ 'lon' => 13.4, 'lat' => 52.5 }) + end + + it 'processes points in batches' do + # Create many points to test batch processing with unique timestamps + many_points = [] + base_time = Time.new(2024, 6, 15).to_i + 2500.times do |i| + many_points << create(:point, user: user, timestamp: base_time + i, raw_data: { lon: 13.4, lat: 52.5 }) + end + large_compressor = described_class.new(Point.where(id: many_points.map(&:id))) + + compressed = large_compressor.compress + + io = StringIO.new(compressed) + gz = Zlib::GzipReader.new(io) + line_count = 0 + gz.each_line { line_count += 1 } + gz.close + + expect(line_count).to eq(2500) + end + + it 'produces smaller compressed output than uncompressed' do + compressed = compressor.compress + + # Decompress to get original size + io = StringIO.new(compressed) + gz = Zlib::GzipReader.new(io) + decompressed = gz.read + gz.close + + # Compressed should be smaller + expect(compressed.bytesize).to be < decompressed.bytesize + end + end +end diff --git a/spec/services/points/raw_data/clearer_spec.rb b/spec/services/points/raw_data/clearer_spec.rb new file mode 100644 index 00000000..536e6fb7 --- /dev/null +++ b/spec/services/points/raw_data/clearer_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawData::Clearer do + let(:user) { create(:user) } + let(:clearer) { described_class.new } + + before do + allow(PointsChannel).to receive(:broadcast_to) + end + + describe '#clear_specific_archive' do + let(:test_date) { 3.months.ago.beginning_of_month.utc } + let!(:points) do + create_list(:point, 5, user: user, + timestamp: test_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + end + + let(:archive) do + # Create and verify archive + archiver = Points::RawData::Archiver.new + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + + archive = Points::RawDataArchive.last + verifier = Points::RawData::Verifier.new + verifier.verify_specific_archive(archive.id) + + archive.reload + end + + it 'clears raw_data for verified archive' do + expect(Point.where(user: user).pluck(:raw_data)).to all(eq({ 'lon' => 13.4, 'lat' => 52.5 })) + + clearer.clear_specific_archive(archive.id) + + expect(Point.where(user: user).pluck(:raw_data)).to all(eq({})) + end + + it 'does not clear unverified archive' do + # Create unverified archive + archiver = Points::RawData::Archiver.new + mid_month = test_date + 15.days + create_list(:point, 3, user: user, + timestamp: mid_month.to_i, + raw_data: { lon: 14.0, lat: 53.0 }) + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + + unverified_archive = Points::RawDataArchive.where(verified_at: nil).last + + result = clearer.clear_specific_archive(unverified_archive.id) + + expect(result[:cleared]).to eq(0) + end + + it 'is idempotent (safe to run multiple times)' do + clearer.clear_specific_archive(archive.id) + first_result = Point.where(user: user).pluck(:raw_data) + + clearer.clear_specific_archive(archive.id) + second_result = Point.where(user: user).pluck(:raw_data) + + expect(first_result).to eq(second_result) + expect(first_result).to all(eq({})) + end + end + + describe '#clear_month' do + let(:test_date) { 3.months.ago.beginning_of_month.utc } + + before do + # Create points and archive + create_list(:point, 5, user: user, + timestamp: test_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + + archiver = Points::RawData::Archiver.new + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + + # Verify archive + verifier = Points::RawData::Verifier.new + verifier.verify_month(user.id, test_date.year, test_date.month) + end + + it 'clears all verified archives for a month' do + expect(Point.where(user: user, raw_data: {}).count).to eq(0) + + clearer.clear_month(user.id, test_date.year, test_date.month) + + expect(Point.where(user: user, raw_data: {}).count).to eq(5) + end + end + + describe '#call' do + let(:test_date) { 3.months.ago.beginning_of_month.utc } + + before do + # Create points and archive + create_list(:point, 5, user: user, + timestamp: test_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + + archiver = Points::RawData::Archiver.new + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + + # Verify archive + verifier = Points::RawData::Verifier.new + verifier.verify_month(user.id, test_date.year, test_date.month) + end + + it 'clears all verified archives' do + expect(Point.where(raw_data: {}).count).to eq(0) + + result = clearer.call + + expect(result[:cleared]).to eq(5) + expect(Point.where(raw_data: {}).count).to eq(5) + end + + it 'skips unverified archives' do + # Create another month without verifying + new_date = 4.months.ago.beginning_of_month.utc + create_list(:point, 3, user: user, + timestamp: new_date.to_i, + raw_data: { lon: 14.0, lat: 53.0 }) + + archiver = Points::RawData::Archiver.new + archiver.archive_specific_month(user.id, new_date.year, new_date.month) + + result = clearer.call + + # Should only clear the verified month (5 points) + expect(result[:cleared]).to eq(5) + + # Unverified month should still have raw_data + unverified_points = Point.where(user: user) + .where("timestamp >= ? AND timestamp < ?", + new_date.to_i, + (new_date + 1.month).to_i) + expect(unverified_points.pluck(:raw_data)).to all(eq({ 'lon' => 14.0, 'lat' => 53.0 })) + end + + it 'is idempotent (safe to run multiple times)' do + first_result = clearer.call + + # Use a new instance for second call + new_clearer = Points::RawData::Clearer.new + second_result = new_clearer.call + + expect(first_result[:cleared]).to eq(5) + expect(second_result[:cleared]).to eq(0) # Already cleared + end + + it 'handles large batches' do + # Stub batch size to test batching logic + stub_const('Points::RawData::Clearer::BATCH_SIZE', 2) + + result = clearer.call + + expect(result[:cleared]).to eq(5) + expect(Point.where(raw_data: {}).count).to eq(5) + end + end +end diff --git a/spec/services/points/raw_data/restorer_spec.rb b/spec/services/points/raw_data/restorer_spec.rb new file mode 100644 index 00000000..03408c73 --- /dev/null +++ b/spec/services/points/raw_data/restorer_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawData::Restorer do + let(:user) { create(:user) } + let(:restorer) { described_class.new } + + before do + # Stub broadcasting to avoid ActionCable issues in tests + allow(PointsChannel).to receive(:broadcast_to) + end + + describe '#restore_to_database' do + let!(:archived_points) do + create_list(:point, 3, user: user, timestamp: Time.new(2024, 6, 15).to_i, + raw_data: nil, raw_data_archived: true) + end + + let(:archive) do + # Create archive with actual point data + compressed_data = gzip_points_data(archived_points.map do |p| + { id: p.id, raw_data: { lon: 13.4, lat: 52.5 } } + end) + + arc = build(:points_raw_data_archive, user: user, year: 2024, month: 6) + arc.file.attach( + io: StringIO.new(compressed_data), + filename: arc.filename, + content_type: 'application/gzip' + ) + arc.save! + + # Associate points with archive + archived_points.each { |p| p.update!(raw_data_archive: arc) } + + arc + end + + it 'restores raw_data to database' do + archive # Ensure archive is created before restore + restorer.restore_to_database(user.id, 2024, 6) + + archived_points.each(&:reload) + archived_points.each do |point| + expect(point.raw_data).to eq({ 'lon' => 13.4, 'lat' => 52.5 }) + end + end + + it 'clears archive flags' do + archive # Ensure archive is created before restore + restorer.restore_to_database(user.id, 2024, 6) + + archived_points.each(&:reload) + archived_points.each do |point| + expect(point.raw_data_archived).to be false + expect(point.raw_data_archive_id).to be_nil + end + end + + it 'raises error when no archives found' do + expect do + restorer.restore_to_database(user.id, 2025, 12) + end.to raise_error(/No archives found/) + end + + context 'with multiple chunks' do + let!(:more_points) do + create_list(:point, 2, user: user, timestamp: Time.new(2024, 6, 20).to_i, + raw_data: nil, raw_data_archived: true) + end + + let!(:archive2) do + compressed_data = gzip_points_data(more_points.map do |p| + { id: p.id, raw_data: { lon: 14.0, lat: 53.0 } } + end) + + arc = build(:points_raw_data_archive, user: user, year: 2024, month: 6, chunk_number: 2) + arc.file.attach( + io: StringIO.new(compressed_data), + filename: arc.filename, + content_type: 'application/gzip' + ) + arc.save! + + more_points.each { |p| p.update!(raw_data_archive: arc) } + + arc + end + + it 'restores from all chunks' do + archive # Ensure first archive is created + archive2 # Ensure second archive is created + restorer.restore_to_database(user.id, 2024, 6) + + (archived_points + more_points).each(&:reload) + expect(archived_points.first.raw_data).to eq({ 'lon' => 13.4, 'lat' => 52.5 }) + expect(more_points.first.raw_data).to eq({ 'lon' => 14.0, 'lat' => 53.0 }) + end + end + end + + describe '#restore_to_memory' do + let!(:archived_points) do + create_list(:point, 2, user: user, timestamp: Time.new(2024, 6, 15).to_i, + raw_data: nil, raw_data_archived: true) + end + + let(:archive) do + compressed_data = gzip_points_data(archived_points.map do |p| + { id: p.id, raw_data: { lon: 13.4, lat: 52.5 } } + end) + + arc = build(:points_raw_data_archive, user: user, year: 2024, month: 6) + arc.file.attach( + io: StringIO.new(compressed_data), + filename: arc.filename, + content_type: 'application/gzip' + ) + arc.save! + + archived_points.each { |p| p.update!(raw_data_archive: arc) } + + arc + end + + it 'loads data into cache' do + archive # Ensure archive is created before restore + restorer.restore_to_memory(user.id, 2024, 6) + + archived_points.each do |point| + cache_key = "raw_data:temp:#{user.id}:2024:6:#{point.id}" + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq({ 'lon' => 13.4, 'lat' => 52.5 }) + end + end + + it 'does not modify database' do + archive # Ensure archive is created before restore + restorer.restore_to_memory(user.id, 2024, 6) + + archived_points.each(&:reload) + archived_points.each do |point| + expect(point.raw_data).to be_nil + expect(point.raw_data_archived).to be true + end + end + + it 'sets cache expiration to 1 hour' do + archive # Ensure archive is created before restore + restorer.restore_to_memory(user.id, 2024, 6) + + cache_key = "raw_data:temp:#{user.id}:2024:6:#{archived_points.first.id}" + + # Cache should exist now + expect(Rails.cache.exist?(cache_key)).to be true + end + end + + describe '#restore_all_for_user' do + let!(:june_points) do + create_list(:point, 2, user: user, timestamp: Time.new(2024, 6, 15).to_i, + raw_data: nil, raw_data_archived: true) + end + + let!(:july_points) do + create_list(:point, 2, user: user, timestamp: Time.new(2024, 7, 15).to_i, + raw_data: nil, raw_data_archived: true) + end + + let!(:june_archive) do + compressed_data = gzip_points_data(june_points.map { |p| { id: p.id, raw_data: { month: 'june' } } }) + + arc = build(:points_raw_data_archive, user: user, year: 2024, month: 6) + arc.file.attach( + io: StringIO.new(compressed_data), + filename: arc.filename, + content_type: 'application/gzip' + ) + arc.save! + + june_points.each { |p| p.update!(raw_data_archive: arc) } + arc + end + + let!(:july_archive) do + compressed_data = gzip_points_data(july_points.map { |p| { id: p.id, raw_data: { month: 'july' } } }) + + arc = build(:points_raw_data_archive, user: user, year: 2024, month: 7) + arc.file.attach( + io: StringIO.new(compressed_data), + filename: arc.filename, + content_type: 'application/gzip' + ) + arc.save! + + july_points.each { |p| p.update!(raw_data_archive: arc) } + arc + end + + it 'restores all months for user' do + restorer.restore_all_for_user(user.id) + + june_points.each(&:reload) + july_points.each(&:reload) + + expect(june_points.first.raw_data).to eq({ 'month' => 'june' }) + expect(july_points.first.raw_data).to eq({ 'month' => 'july' }) + end + + it 'clears all archive flags' do + restorer.restore_all_for_user(user.id) + + (june_points + july_points).each(&:reload) + expect(Point.where(user: user, raw_data_archived: true).count).to eq(0) + end + end + + def gzip_points_data(points_array) + io = StringIO.new + gz = Zlib::GzipWriter.new(io) + points_array.each do |point_data| + gz.puts(point_data.to_json) + end + gz.close + io.string + end +end diff --git a/spec/services/points/raw_data/verifier_spec.rb b/spec/services/points/raw_data/verifier_spec.rb new file mode 100644 index 00000000..5611748a --- /dev/null +++ b/spec/services/points/raw_data/verifier_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::RawData::Verifier do + let(:user) { create(:user) } + let(:verifier) { described_class.new } + + before do + allow(PointsChannel).to receive(:broadcast_to) + end + + describe '#verify_specific_archive' do + let(:test_date) { 3.months.ago.beginning_of_month.utc } + let!(:points) do + create_list(:point, 5, user: user, + timestamp: test_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + end + + let(:archive) do + # Create archive + archiver = Points::RawData::Archiver.new + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + Points::RawDataArchive.last + end + + it 'verifies a valid archive successfully' do + expect(archive.verified_at).to be_nil + + verifier.verify_specific_archive(archive.id) + archive.reload + + expect(archive.verified_at).to be_present + end + + it 'detects missing file' do + archive.file.purge + archive.reload + + expect do + verifier.verify_specific_archive(archive.id) + end.not_to change { archive.reload.verified_at } + end + + it 'detects point count mismatch' do + # Tamper with point count + archive.update_column(:point_count, 999) + + expect do + verifier.verify_specific_archive(archive.id) + end.not_to change { archive.reload.verified_at } + end + + it 'detects checksum mismatch' do + # Tamper with checksum + archive.update_column(:point_ids_checksum, 'invalid') + + expect do + verifier.verify_specific_archive(archive.id) + end.not_to change { archive.reload.verified_at } + end + + it 'detects deleted points' do + # Force archive creation first + archive_id = archive.id + + # Then delete one point from database + points.first.destroy + + expect do + verifier.verify_specific_archive(archive_id) + end.not_to change { archive.reload.verified_at } + end + + it 'detects raw_data mismatch between archive and database' do + # Force archive creation first + archive_id = archive.id + + # Then modify raw_data in database after archiving + points.first.update_column(:raw_data, { lon: 999.0, lat: 999.0 }) + + expect do + verifier.verify_specific_archive(archive_id) + end.not_to change { archive.reload.verified_at } + end + + it 'verifies raw_data matches between archive and database' do + # Ensure data hasn't changed + expect(points.first.raw_data).to eq({ 'lon' => 13.4, 'lat' => 52.5 }) + + verifier.verify_specific_archive(archive.id) + + expect(archive.reload.verified_at).to be_present + end + end + + describe '#verify_month' do + let(:test_date) { 3.months.ago.beginning_of_month.utc } + + before do + # Create points + create_list(:point, 5, user: user, + timestamp: test_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + + # Archive them + archiver = Points::RawData::Archiver.new + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + end + + it 'verifies all archives for a month' do + expect(Points::RawDataArchive.where(verified_at: nil).count).to eq(1) + + verifier.verify_month(user.id, test_date.year, test_date.month) + + expect(Points::RawDataArchive.where(verified_at: nil).count).to eq(0) + end + end + + describe '#call' do + let(:test_date) { 3.months.ago.beginning_of_month.utc } + + before do + # Create points and archive + create_list(:point, 5, user: user, + timestamp: test_date.to_i, + raw_data: { lon: 13.4, lat: 52.5 }) + + archiver = Points::RawData::Archiver.new + archiver.archive_specific_month(user.id, test_date.year, test_date.month) + end + + it 'verifies all unverified archives' do + expect(Points::RawDataArchive.where(verified_at: nil).count).to eq(1) + + result = verifier.call + + expect(result[:verified]).to eq(1) + expect(result[:failed]).to eq(0) + expect(Points::RawDataArchive.where(verified_at: nil).count).to eq(0) + end + + it 'reports failures' do + # Tamper with archive + Points::RawDataArchive.last.update_column(:point_count, 999) + + result = verifier.call + + expect(result[:verified]).to eq(0) + expect(result[:failed]).to eq(1) + end + + it 'skips already verified archives' do + # Verify once + verifier.call + + # Try to verify again with a new verifier instance + new_verifier = Points::RawData::Verifier.new + result = new_verifier.call + + expect(result[:verified]).to eq(0) + expect(result[:failed]).to eq(0) + end + end +end diff --git a/spec/services/stats/calculate_month_spec.rb b/spec/services/stats/calculate_month_spec.rb index 275c46a9..969926f9 100644 --- a/spec/services/stats/calculate_month_spec.rb +++ b/spec/services/stats/calculate_month_spec.rb @@ -93,6 +93,114 @@ RSpec.describe Stats::CalculateMonth do expect(user.stats.last.distance).to be_within(1000).of(340_000) end end + + context 'when calculating visited cities and countries' do + let(:timestamp_base) { DateTime.new(year, month, 1, 12).to_i } + let!(:import) { create(:import, user:) } + + context 'when user spent more than MIN_MINUTES_SPENT_IN_CITY in a city' do + let!(:berlin_points) do + [ + create(:point, user:, import:, timestamp: timestamp_base, + city: 'Berlin', country_name: 'Germany', + lonlat: 'POINT(13.404954 52.520008)'), + create(:point, user:, import:, timestamp: timestamp_base + 30.minutes, + city: 'Berlin', country_name: 'Germany', + lonlat: 'POINT(13.404954 52.520008)'), + create(:point, user:, import:, timestamp: timestamp_base + 70.minutes, + city: 'Berlin', country_name: 'Germany', + lonlat: 'POINT(13.404954 52.520008)') + ] + end + + it 'includes the city in toponyms' do + calculate_stats + + stat = user.stats.last + expect(stat.toponyms).not_to be_empty + expect(stat.toponyms.first['country']).to eq('Germany') + expect(stat.toponyms.first['cities']).not_to be_empty + expect(stat.toponyms.first['cities'].first['city']).to eq('Berlin') + end + end + + context 'when user spent less than MIN_MINUTES_SPENT_IN_CITY in a city' do + let!(:prague_points) do + [ + create(:point, user:, import:, timestamp: timestamp_base, + city: 'Prague', country_name: 'Czech Republic', + lonlat: 'POINT(14.4378 50.0755)'), + create(:point, user:, import:, timestamp: timestamp_base + 10.minutes, + city: 'Prague', country_name: 'Czech Republic', + lonlat: 'POINT(14.4378 50.0755)'), + create(:point, user:, import:, timestamp: timestamp_base + 20.minutes, + city: 'Prague', country_name: 'Czech Republic', + lonlat: 'POINT(14.4378 50.0755)') + ] + end + + it 'excludes the city from toponyms' do + calculate_stats + + stat = user.stats.last + expect(stat.toponyms).not_to be_empty + + # Country should be listed but with no cities + czech_country = stat.toponyms.find { |t| t['country'] == 'Czech Republic' } + expect(czech_country).not_to be_nil + expect(czech_country['cities']).to be_empty + end + end + + context 'when user visited multiple cities with mixed durations' do + let!(:mixed_points) do + [ + # Berlin: 70 minutes (should be included) + create(:point, user:, import:, timestamp: timestamp_base, + city: 'Berlin', country_name: 'Germany', + lonlat: 'POINT(13.404954 52.520008)'), + create(:point, user:, import:, timestamp: timestamp_base + 70.minutes, + city: 'Berlin', country_name: 'Germany', + lonlat: 'POINT(13.404954 52.520008)'), + + # Prague: 20 minutes (should be excluded) + create(:point, user:, import:, timestamp: timestamp_base + 100.minutes, + city: 'Prague', country_name: 'Czech Republic', + lonlat: 'POINT(14.4378 50.0755)'), + create(:point, user:, import:, timestamp: timestamp_base + 120.minutes, + city: 'Prague', country_name: 'Czech Republic', + lonlat: 'POINT(14.4378 50.0755)'), + + # Vienna: 90 minutes (should be included) + create(:point, user:, import:, timestamp: timestamp_base + 150.minutes, + city: 'Vienna', country_name: 'Austria', + lonlat: 'POINT(16.3738 48.2082)'), + create(:point, user:, import:, timestamp: timestamp_base + 240.minutes, + city: 'Vienna', country_name: 'Austria', + lonlat: 'POINT(16.3738 48.2082)') + ] + end + + it 'only includes cities where user spent >= MIN_MINUTES_SPENT_IN_CITY' do + calculate_stats + + stat = user.stats.last + expect(stat.toponyms).not_to be_empty + + # Get all cities from all countries + all_cities = stat.toponyms.flat_map { |t| t['cities'].map { |c| c['city'] } } + + # Berlin and Vienna should be included + expect(all_cities).to include('Berlin', 'Vienna') + + # Prague should NOT be included + expect(all_cities).not_to include('Prague') + + # Should have exactly 2 cities + expect(all_cities.size).to eq(2) + end + end + end end end end diff --git a/spec/swagger/api/v1/stats_controller_spec.rb b/spec/swagger/api/v1/stats_controller_spec.rb index b1fda703..a3ec8a2f 100644 --- a/spec/swagger/api/v1/stats_controller_spec.rb +++ b/spec/swagger/api/v1/stats_controller_spec.rb @@ -55,8 +55,8 @@ describe 'Stats API', type: :request do ] let!(:user) { create(:user) } - let!(:stats_in_2020) { create_list(:stat, 12, year: 2020, user:) } - let!(:stats_in_2021) { create_list(:stat, 12, year: 2021, user:) } + let!(:stats_in_2020) { (1..12).map { |month| create(:stat, year: 2020, month:, user:) } } + let!(:stats_in_2021) { (1..12).map { |month| create(:stat, year: 2021, month:, user:) } } let!(:points_in_2020) do (1..85).map do |i| create(:point, :with_geodata, :reverse_geocoded, timestamp: Time.zone.local(2020, 1, 1).to_i + i.hours,