From 946377ef6356ac91b5bc74f35e45ccec2350e1e3 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 25 Nov 2024 16:02:39 +0100
Subject: [PATCH 01/84] Add production environment configuration
---
.github/workflows/build_and_push.yml | 14 ++-
Dockerfile | 2 +-
config/environments/production.rb | 28 +++--
docker-compose.production.yml | 158 +++++++++++++++++++++++++++
prod-docker-entrypoint.sh | 51 +++++++++
5 files changed, 241 insertions(+), 12 deletions(-)
create mode 100644 docker-compose.production.yml
create mode 100644 prod-docker-entrypoint.sh
diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml
index 1be0cca5..812f071f 100644
--- a/.github/workflows/build_and_push.yml
+++ b/.github/workflows/build_and_push.yml
@@ -1,13 +1,23 @@
name: Docker image build and push
+
on:
workflow_dispatch:
+ inputs:
+ branch:
+ description: "The branch to build the Docker image from"
+ required: false
+ default: "main"
release:
types: [created]
+
jobs:
build-and-push-docker:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - name: Checkout code
+ uses: actions/checkout@v2
+ with:
+ ref: ${{ github.event.inputs.branch || github.ref_name }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
@@ -32,7 +42,7 @@ jobs:
context: .
file: ./Dockerfile
push: true
- tags: freikin/dawarich:latest,freikin/dawarich:${{ github.event.release.tag_name }}
+ tags: freikin/dawarich:latest,freikin/dawarich:${{ github.event.inputs.branch || github.ref_name }}
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
diff --git a/Dockerfile b/Dockerfile
index d809b2d7..ef2b74c5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
FROM ruby:3.3.4-alpine
ENV APP_PATH=/var/app
-ENV BUNDLE_VERSION=2.5.9
+ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV TMP_PATH=/tmp/
ENV RAILS_LOG_TO_STDOUT=true
diff --git a/config/environments/production.rb b/config/environments/production.rb
index f541929a..d0d64c4d 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -1,4 +1,6 @@
-require "active_support/core_ext/integer/time"
+# frozen_string_literal: true
+
+require 'active_support/core_ext/integer/time'
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
@@ -27,7 +29,11 @@ Rails.application.configure do
# config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed.
- config.assets.compile = false
+ config.assets.compile = true
+
+ config.assets.content_type = {
+ geojson: 'application/geo+json'
+ }
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
@@ -49,20 +55,20 @@ Rails.application.configure do
# config.assume_ssl = true
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
- config.force_ssl = true
+ config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https'
- # Log to STDOUT by default
- config.logger = ActiveSupport::Logger.new(STDOUT)
- .tap { |logger| logger.formatter = ::Logger::Formatter.new }
- .then { |logger| ActiveSupport::TaggedLogging.new(logger) }
+ # Direct logs to STDOUT
+ config.logger = Logger.new($stdout)
+ config.lograge.enabled = true
+ config.lograge.formatter = Lograge::Formatters::Json.new
# Prepend all log lines with the following tags.
- config.log_tags = [ :request_id ]
+ config.log_tags = [:request_id]
# Info include generic and useful information about system operation, but avoids logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII). If you
# want to log everything, set the level to "debug".
- config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
+ config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
# Use a different cache store in production.
# config.cache_store = :mem_cache_store
@@ -95,4 +101,8 @@ Rails.application.configure do
# ]
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
+ config.hosts << ENV.fetch('APPLICATION_HOST', 'localhost')
+
+ hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost')
+ config.hosts.concat(hosts.split(',')) if hosts.present?
end
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
new file mode 100644
index 00000000..e6a39b90
--- /dev/null
+++ b/docker-compose.production.yml
@@ -0,0 +1,158 @@
+networks:
+ dawarich:
+services:
+ dawarich_redis:
+ image: redis:7.0-alpine
+ container_name: dawarich_redis
+ command: redis-server
+ networks:
+ - dawarich
+ volumes:
+ - shared_data:/var/shared/redis
+ restart: always
+ healthcheck:
+ test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ dawarich_db:
+ image: postgres:14.2-alpine
+ container_name: dawarich_db
+ volumes:
+ - db_data:/var/lib/postgresql/data
+ - shared_data:/var/shared
+ networks:
+ - dawarich
+ environment:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: password
+ restart: always
+ healthcheck:
+ test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ dawarich_app:
+ image: freikin/dawarich:latest
+ container_name: dawarich_app
+ volumes:
+ - gem_cache:/usr/local/bundle/gems_app
+ - public:/var/app/public
+ - watched:/var/app/tmp/imports/watched
+ networks:
+ - dawarich
+ ports:
+ - 3000:3000
+ # - 9394:9394 # Prometheus exporter, uncomment if needed
+ stdin_open: true
+ tty: true
+ entrypoint: dev-entrypoint.sh
+ command: ['bin/dev']
+ restart: on-failure
+ environment:
+ RAILS_ENV: production
+ REDIS_URL: redis://dawarich_redis:6379/0
+ DATABASE_HOST: dawarich_db
+ DATABASE_USERNAME: postgres
+ DATABASE_PASSWORD: password
+ DATABASE_NAME: dawarich_development
+ MIN_MINUTES_SPENT_IN_CITY: 60
+ APPLICATION_HOST: localhost
+ APPLICATION_HOSTS: localhost
+ TIME_ZONE: Europe/London
+ APPLICATION_PROTOCOL: http
+ DISTANCE_UNIT: km
+ PHOTON_API_HOST: photon.komoot.io
+ PHOTON_API_USE_HTTPS: true
+ PROMETHEUS_EXPORTER_ENABLED: false
+ PROMETHEUS_EXPORTER_HOST: 0.0.0.0
+ PROMETHEUS_EXPORTER_PORT: 9394
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "100m"
+ max-file: "5"
+ healthcheck:
+ test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ]
+ interval: 10s
+ retries: 30
+ start_period: 30s
+ timeout: 10s
+ depends_on:
+ dawarich_db:
+ condition: service_healthy
+ restart: true
+ dawarich_redis:
+ condition: service_healthy
+ restart: true
+ deploy:
+ resources:
+ limits:
+ cpus: '0.50' # Limit CPU usage to 50% of one core
+ memory: '2G' # Limit memory usage to 2GB
+ dawarich_sidekiq:
+ image: freikin/dawarich:latest
+ container_name: dawarich_sidekiq
+ volumes:
+ - gem_cache:/usr/local/bundle/gems_sidekiq
+ - public:/var/app/public
+ - watched:/var/app/tmp/imports/watched
+ networks:
+ - dawarich
+ stdin_open: true
+ tty: true
+ entrypoint: dev-entrypoint.sh
+ command: ['sidekiq']
+ restart: on-failure
+ environment:
+ RAILS_ENV: production
+ REDIS_URL: redis://dawarich_redis:6379/0
+ DATABASE_HOST: dawarich_db
+ DATABASE_USERNAME: postgres
+ DATABASE_PASSWORD: password
+ DATABASE_NAME: dawarich_development
+ APPLICATION_HOST: localhost
+ APPLICATION_HOSTS: localhost
+ BACKGROUND_PROCESSING_CONCURRENCY: 10
+ APPLICATION_PROTOCOL: http
+ DISTANCE_UNIT: km
+ PHOTON_API_HOST: photon.komoot.io
+ PHOTON_API_USE_HTTPS: true
+ PROMETHEUS_EXPORTER_ENABLED: false
+ PROMETHEUS_EXPORTER_HOST: dawarich_app
+ PROMETHEUS_EXPORTER_PORT: 9394
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "100m"
+ max-file: "5"
+ healthcheck:
+ test: [ "CMD-SHELL", "bundle exec sidekiqmon processes | grep $${HOSTNAME}" ]
+ interval: 10s
+ retries: 30
+ start_period: 30s
+ timeout: 10s
+ depends_on:
+ dawarich_db:
+ condition: service_healthy
+ restart: true
+ dawarich_redis:
+ condition: service_healthy
+ restart: true
+ dawarich_app:
+ condition: service_healthy
+ restart: true
+ deploy:
+ resources:
+ limits:
+ cpus: '0.50' # Limit CPU usage to 50% of one core
+ memory: '2G' # Limit memory usage to 2GB
+
+volumes:
+ db_data:
+ gem_cache:
+ shared_data:
+ public:
+ watched:
diff --git a/prod-docker-entrypoint.sh b/prod-docker-entrypoint.sh
new file mode 100644
index 00000000..52bac19a
--- /dev/null
+++ b/prod-docker-entrypoint.sh
@@ -0,0 +1,51 @@
+#!/bin/sh
+
+unset BUNDLE_PATH
+unset BUNDLE_BIN
+
+set -e
+
+echo "Environment: $RAILS_ENV"
+
+# set env var defaults
+DATABASE_HOST=${DATABASE_HOST:-"dawarich_db"}
+DATABASE_PORT=${DATABASE_PORT:-5432}
+DATABASE_USERNAME=${DATABASE_USERNAME:-"postgres"}
+DATABASE_PASSWORD=${DATABASE_PASSWORD:-"password"}
+DATABASE_NAME=${DATABASE_NAME:-"dawarich_production"}
+
+# Remove pre-existing puma/passenger server.pid
+rm -f $APP_PATH/tmp/pids/server.pid
+
+# Wait for the database to be ready
+until nc -zv $DATABASE_HOST ${DATABASE_PORT:-5432}; do
+ echo "Waiting for PostgreSQL to be ready..."
+ sleep 1
+done
+
+# Install gems
+gem update --system 3.5.23
+gem install bundler --version '2.5.21'
+
+# Create the database
+if [ "$(psql "postgres://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT" -XtAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'")" = '1' ]; then
+ echo "Database $DATABASE_NAME already exists, skipping creation..."
+else
+ echo "Creating database $DATABASE_NAME..."
+ bundle exec rails db:create
+fi
+
+# Run database migrations
+echo "PostgreSQL is ready. Running database migrations..."
+bundle exec rails db:prepare
+
+# Run data migrations
+echo "Running DATA migrations..."
+bundle exec rake data:migrate
+
+# Run seeds
+echo "Running seeds..."
+bundle exec rake db:seed
+
+# run passed commands
+bundle exec ${@}
From a6b04ba49ad582de4fce6008d1915980b0b3f6f2 Mon Sep 17 00:00:00 2001
From: Arne Schwarck
Date: Fri, 27 Dec 2024 21:52:33 +0100
Subject: [PATCH 02/84] Add Logging for Immich
---
app/services/immich/request_photos.rb | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/services/immich/request_photos.rb b/app/services/immich/request_photos.rb
index 59baa496..0d3f6e1f 100644
--- a/app/services/immich/request_photos.rb
+++ b/app/services/immich/request_photos.rb
@@ -34,7 +34,8 @@ class Immich::RequestPhotos
immich_api_base_url, headers: headers, body: request_body(page)
).body
)
-
+ Rails.logger.debug('==== IMMICH RESPONSE ====')
+ Rails.logger.debug(response)
items = response.dig('assets', 'items')
break if items.blank?
From a43f2c6a1dddf74c42e07cfee4da48634302805c Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Sat, 28 Dec 2024 15:21:35 +0100
Subject: [PATCH 03/84] Update changelog
---
CHANGELOG.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4498099e..119848e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.21.2 - 2024-12-25
+### Added
+
+- Logging for Immich responses.
+- Watcher now supports all data formats that can be imported via web interface.
+
### Changed
- Imported points will now be reverse geocoded only after import is finished.
From cdc884b5c977cde6f7fbb2dc2ba238a20d4e662d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 30 Dec 2024 15:01:45 +0000
Subject: [PATCH 04/84] Bump tailwindcss-rails from 3.0.0 to 3.1.0
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v3.0.0...v3.1.0)
---
updated-dependencies:
- dependency-name: tailwindcss-rails
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
Gemfile.lock | 32 +++++++++++++++++---------------
1 file changed, 17 insertions(+), 15 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 47ea71bd..7ea87fd9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -88,7 +88,7 @@ GEM
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
- bigdecimal (3.1.8)
+ bigdecimal (3.1.9)
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
@@ -197,6 +197,7 @@ GEM
marcel (1.0.4)
method_source (1.1.0)
mini_mime (1.1.5)
+ mini_portile2 (2.8.8)
minitest (5.25.4)
msgpack (1.7.3)
multi_xml (0.7.1)
@@ -211,17 +212,18 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.4)
- nokogiri (1.17.2-aarch64-linux)
+ nokogiri (1.18.1)
+ mini_portile2 (~> 2.8.2)
racc (~> 1.4)
- nokogiri (1.17.2-arm-linux)
+ nokogiri (1.18.1-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.17.2-arm64-darwin)
+ nokogiri (1.18.1-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.17.2-x86-linux)
+ nokogiri (1.18.1-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.17.2-x86_64-darwin)
+ nokogiri (1.18.1-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.17.2-x86_64-linux)
+ nokogiri (1.18.1-x86_64-linux-gnu)
racc (~> 1.4)
oj (3.16.8)
bigdecimal (>= 3.0)
@@ -259,7 +261,7 @@ GEM
rack (3.1.8)
rack-session (2.0.0)
rack (>= 3.0.0)
- rack-test (2.1.0)
+ rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
@@ -393,15 +395,15 @@ GEM
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
- tailwindcss-rails (3.0.0)
+ tailwindcss-rails (3.1.0)
railties (>= 7.0.0)
tailwindcss-ruby
- tailwindcss-ruby (3.4.14)
- tailwindcss-ruby (3.4.14-aarch64-linux)
- tailwindcss-ruby (3.4.14-arm-linux)
- tailwindcss-ruby (3.4.14-arm64-darwin)
- tailwindcss-ruby (3.4.14-x86_64-darwin)
- tailwindcss-ruby (3.4.14-x86_64-linux)
+ tailwindcss-ruby (3.4.17)
+ tailwindcss-ruby (3.4.17-aarch64-linux)
+ tailwindcss-ruby (3.4.17-arm-linux)
+ tailwindcss-ruby (3.4.17-arm64-darwin)
+ tailwindcss-ruby (3.4.17-x86_64-darwin)
+ tailwindcss-ruby (3.4.17-x86_64-linux)
thor (1.3.2)
timeout (0.4.2)
turbo-rails (2.0.11)
From dd3a2e4f8b294ee5dd11d175517b0301bbd69d11 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 30 Dec 2024 15:02:25 +0000
Subject: [PATCH 05/84] Bump oj from 3.16.8 to 3.16.9
Bumps [oj](https://github.com/ohler55/oj) from 3.16.8 to 3.16.9.
- [Release notes](https://github.com/ohler55/oj/releases)
- [Changelog](https://github.com/ohler55/oj/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/ohler55/oj/compare/v3.16.8...v3.16.9)
---
updated-dependencies:
- dependency-name: oj
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
Gemfile.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 47ea71bd..74c80a7b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -88,7 +88,7 @@ GEM
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
- bigdecimal (3.1.8)
+ bigdecimal (3.1.9)
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
@@ -223,7 +223,7 @@ GEM
racc (~> 1.4)
nokogiri (1.17.2-x86_64-linux)
racc (~> 1.4)
- oj (3.16.8)
+ oj (3.16.9)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
optimist (3.2.0)
From 8ad5a4cd2d82a042bca1bce603c65a5efd57aef1 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 30 Dec 2024 15:02:39 +0000
Subject: [PATCH 06/84] Bump rubocop-rails from 2.27.0 to 2.28.0
Bumps [rubocop-rails](https://github.com/rubocop/rubocop-rails) from 2.27.0 to 2.28.0.
- [Release notes](https://github.com/rubocop/rubocop-rails/releases)
- [Changelog](https://github.com/rubocop/rubocop-rails/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rubocop/rubocop-rails/compare/v2.27.0...v2.28.0)
---
updated-dependencies:
- dependency-name: rubocop-rails
dependency-type: direct:development
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
Gemfile.lock | 24 +++++++++++++-----------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 47ea71bd..53434767 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -88,7 +88,7 @@ GEM
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
- bigdecimal (3.1.8)
+ bigdecimal (3.1.9)
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
@@ -164,7 +164,7 @@ GEM
irb (1.14.3)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
- json (2.7.4)
+ json (2.9.1)
json-schema (5.0.1)
addressable (~> 2.8)
kaminari (1.2.2)
@@ -230,7 +230,7 @@ GEM
orm_adapter (0.5.0)
ostruct (0.6.1)
parallel (1.26.3)
- parser (3.3.5.0)
+ parser (3.3.6.0)
ast (~> 2.4.1)
racc
patience_diff (1.2.0)
@@ -300,7 +300,7 @@ GEM
redis-client (>= 0.22.0)
redis-client (0.23.0)
connection_pool
- regexp_parser (2.9.2)
+ regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
request_store (1.7.0)
@@ -337,19 +337,19 @@ GEM
rswag-ui (2.16.0)
actionpack (>= 5.2, < 8.1)
railties (>= 5.2, < 8.1)
- rubocop (1.67.0)
+ rubocop (1.69.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
- regexp_parser (>= 2.4, < 3.0)
- rubocop-ast (>= 1.32.2, < 2.0)
+ regexp_parser (>= 2.9.3, < 3.0)
+ rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7)
- unicode-display_width (>= 2.4.0, < 3.0)
- rubocop-ast (1.32.3)
+ unicode-display_width (>= 2.4.0, < 4.0)
+ rubocop-ast (1.37.0)
parser (>= 3.3.1.0)
- rubocop-rails (2.27.0)
+ rubocop-rails (2.28.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.52.0, < 2.0)
@@ -410,7 +410,9 @@ GEM
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
- unicode-display_width (2.6.0)
+ unicode-display_width (3.1.3)
+ unicode-emoji (~> 4.0, >= 4.0.4)
+ unicode-emoji (4.0.4)
uri (1.0.2)
useragent (0.16.11)
warden (1.2.9)
From 1e9f539dac29f65afcbde40d932dee8fce4b630d Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Sat, 4 Jan 2025 21:31:21 +0100
Subject: [PATCH 07/84] Revert "Imported points will now be reverse geocoded
only after import is finished."
---
.app_version | 2 +-
app/models/point.rb | 3 +--
app/services/imports/create.rb | 5 -----
app/services/jobs/create.rb | 2 +-
spec/models/point_spec.rb | 10 ++--------
spec/services/imports/create_spec.rb | 5 -----
6 files changed, 5 insertions(+), 22 deletions(-)
diff --git a/.app_version b/.app_version
index 59dad104..16eb94e7 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.21.2
+0.21.3
diff --git a/app/models/point.rb b/app/models/point.rb
index 26984c3e..bec501b4 100644
--- a/app/models/point.rb
+++ b/app/models/point.rb
@@ -33,9 +33,8 @@ class Point < ApplicationRecord
Time.zone.at(timestamp)
end
- def async_reverse_geocode(force: false)
+ def async_reverse_geocode
return unless REVERSE_GEOCODING_ENABLED
- return if import_id.present? && !force
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb
index 78ddf9cd..af9b0d0c 100644
--- a/app/services/imports/create.rb
+++ b/app/services/imports/create.rb
@@ -15,7 +15,6 @@ class Imports::Create
schedule_stats_creating(user.id)
schedule_visit_suggesting(user.id, import)
- schedule_reverse_geocoding(user.id)
rescue StandardError => e
create_import_failed_notification(import, user, e)
end
@@ -48,10 +47,6 @@ class Imports::Create
VisitSuggestingJob.perform_later(user_ids: [user_id], start_at:, end_at:)
end
- def schedule_reverse_geocoding(user_id)
- EnqueueBackgroundJob.perform_later('continue_reverse_geocoding', user_id)
- end
-
def create_import_finished_notification(import, user)
Notifications::Create.new(
user:,
diff --git a/app/services/jobs/create.rb b/app/services/jobs/create.rb
index 6e301146..bbbcb15c 100644
--- a/app/services/jobs/create.rb
+++ b/app/services/jobs/create.rb
@@ -22,7 +22,7 @@ class Jobs::Create
end
points.find_each(batch_size: 1_000) do |point|
- point.async_reverse_geocode(force: true)
+ point.async_reverse_geocode
end
end
end
diff --git a/spec/models/point_spec.rb b/spec/models/point_spec.rb
index b2e98bf2..ece3ea71 100644
--- a/spec/models/point_spec.rb
+++ b/spec/models/point_spec.rb
@@ -56,14 +56,8 @@ RSpec.describe Point, type: :model do
context 'when point is imported' do
let(:point) { build(:point, import_id: 1) }
- it 'does not enqueue ReverseGeocodeJob' do
- expect { point.async_reverse_geocode }.not_to have_enqueued_job(ReverseGeocodingJob)
- end
-
- context 'when reverse geocoding is forced' do
- it 'enqueues ReverseGeocodeJob' do
- expect { point.async_reverse_geocode(force: true) }.to have_enqueued_job(ReverseGeocodingJob)
- end
+ it 'enqueues ReverseGeocodeJob' do
+ expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
end
end
end
diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb
index 908eba72..85f2131a 100644
--- a/spec/services/imports/create_spec.rb
+++ b/spec/services/imports/create_spec.rb
@@ -55,11 +55,6 @@ RSpec.describe Imports::Create do
expect { service.call }.to have_enqueued_job(VisitSuggestingJob)
end
end
-
- it 'schedules reverse geocoding' do
- expect { service.call }.to \
- have_enqueued_job(EnqueueBackgroundJob).with('continue_reverse_geocoding', user.id)
- end
end
context 'when import fails' do
From f366da9df48ba251493eb053ef03b20e7941d5d7 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Sat, 4 Jan 2025 21:57:24 +0100
Subject: [PATCH 08/84] Add notification about Photon API being under heavy
load
---
CHANGELOG.md | 14 +++++++
...206163450_create_telemetry_notification.rb | 11 +++---
...4204852_create_photon_load_notification.rb | 37 +++++++++++++++++++
db/data_schema.rb | 2 +-
db/schema.rb | 4 +-
5 files changed, 60 insertions(+), 8 deletions(-)
create mode 100644 db/data/20250104204852_create_photon_load_notification.rb
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 119848e5..67fb87fc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ 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.21.3 - 2025-01-04
+
+### Added
+
+- A notification about Photon API being under heavy load.
+
+### Removed
+
+- The notification about telemetry being enabled.
+
+### Reverted
+
+- ~~Imported points will now be reverse geocoded only after import is finished.~~
+
# 0.21.2 - 2024-12-25
### Added
diff --git a/db/data/20241206163450_create_telemetry_notification.rb b/db/data/20241206163450_create_telemetry_notification.rb
index bd5f6dd2..2d9f93d4 100644
--- a/db/data/20241206163450_create_telemetry_notification.rb
+++ b/db/data/20241206163450_create_telemetry_notification.rb
@@ -2,11 +2,12 @@
class CreateTelemetryNotification < ActiveRecord::Migration[7.2]
def up
- User.find_each do |user|
- Notifications::Create.new(
- user:, kind: :info, title: 'Telemetry enabled', content: notification_content
- ).call
- end
+ # TODO: Remove
+ # User.find_each do |user|
+ # Notifications::Create.new(
+ # user:, kind: :info, title: 'Telemetry enabled', content: notification_content
+ # ).call
+ # end
end
def down
diff --git a/db/data/20250104204852_create_photon_load_notification.rb b/db/data/20250104204852_create_photon_load_notification.rb
new file mode 100644
index 00000000..0b8009fe
--- /dev/null
+++ b/db/data/20250104204852_create_photon_load_notification.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class CreatePhotonLoadNotification < ActiveRecord::Migration[8.0]
+ def up
+ User.find_each do |user|
+ Notifications::Create.new(
+ user:, kind: :info, title: '⚠️ Photon API is under heavy load', content: notification_content
+ ).call
+ end
+ end
+
+ def down
+ raise ActiveRecord::IrreversibleMigration
+ end
+
+ private
+
+ def notification_content
+ <<~CONTENT
+
+ A few days ago @lonvia, maintainer of https://photon.komoot.io, the reverse-geocoding API service that Dawarich is using by default, reached me to highlight a problem: Dawarich makes too many requests to https://photon.komoot.io, even with recently introduced rate-limiting to prevent more than 1 request per second.
+
+
+
+
+
+ Photon is a great service and Dawarich wouldn't be what it is now without it, but I have to ask all Dawarich users that are running it on their hardware to either switch to a Photon instance hosted by me (Freika) or strongly consider hosting their own Photon instance. Thanks to @rtuszik, it's pretty much docker compose up -d. The documentation on the website will be soon updated to also encourage setting up your own Photon instance. More reverse geocoding options will be added in the future.
+
+
+
Let's decrease load on https://photon.komoot.io together!
+
+
+
+
Thank you.
+ CONTENT
+ end
+end
diff --git a/db/data_schema.rb b/db/data_schema.rb
index e14cf9d4..222b8d11 100644
--- a/db/data_schema.rb
+++ b/db/data_schema.rb
@@ -1 +1 @@
-DataMigrate::Data.define(version: 20241107112451)
+DataMigrate::Data.define(version: 20250104204852)
diff --git a/db/schema.rb b/db/schema.rb
index a79c53a9..16db4226 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,9 +10,9 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2024_12_11_113119) do
+ActiveRecord::Schema[8.0].define(version: 2024_12_11_113119) do
# These are extensions that must be enabled in order to support this database
- enable_extension "plpgsql"
+ enable_extension "pg_catalog.plpgsql"
create_table "action_text_rich_texts", force: :cascade do |t|
t.string "name", null: false
From 10afb3fbc262cc5a7583a5ec87dc21bebefa1257 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Sun, 5 Jan 2025 21:40:59 +0100
Subject: [PATCH 09/84] Fix Photon API for patreon supporters
---
.app_version | 2 +-
.github/workflows/build_and_push.yml | 2 +-
CHANGELOG.md | 6 ++++++
config/initializers/01_constants.rb | 1 +
4 files changed, 9 insertions(+), 2 deletions(-)
diff --git a/.app_version b/.app_version
index 16eb94e7..6aec9e54 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.21.3
+0.21.4
diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml
index 001c11e4..2c1ebe4c 100644
--- a/.github/workflows/build_and_push.yml
+++ b/.github/workflows/build_and_push.yml
@@ -23,7 +23,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
- uses: actions/cache@v2
+ uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 67fb87fc..8962c791 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ 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.21.4 - 2025-01-05
+
+### Fixed
+
+- Fixed a bug where Photon API for patreon supporters was not being used for reverse geocoding.
+
# 0.21.3 - 2025-01-04
### Added
diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb
index d8cc2d81..bfa380b6 100644
--- a/config/initializers/01_constants.rb
+++ b/config/initializers/01_constants.rb
@@ -3,6 +3,7 @@
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true'
PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil)
+PHOTON_API_KEY = ENV.fetch('PHOTON_API_KEY', nil)
PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true'
DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym
APP_VERSION = File.read('.app_version').strip
From ae6dc5ac8a1fd7ac5f195bfc1552ba8739561bea Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 7 Jan 2025 13:12:14 +0100
Subject: [PATCH 10/84] Remove Photon API env vars and use DawarichSettings for
reverse geocoding settings
---
.app_version | 2 +-
.devcontainer/docker-compose.yml | 2 -
.env.development | 1 -
CHANGELOG.md | 12 +++
app/jobs/reverse_geocoding_job.rb | 4 +-
.../reverse_geocoding/places/fetch_data.rb | 4 +-
config/initializers/01_constants.rb | 16 +++-
config/initializers/03_dawarich_settings.rb | 21 +++++
config/initializers/geocoder.rb | 8 +-
config/initializers/sidekiq.rb | 2 +-
docker-compose.yml | 4 -
docker-compose_mounted_volumes.yml | 4 -
spec/lib/dawarich_settings_spec.rb | 94 +++++++++++++++++++
13 files changed, 150 insertions(+), 24 deletions(-)
create mode 100644 config/initializers/03_dawarich_settings.rb
create mode 100644 spec/lib/dawarich_settings_spec.rb
diff --git a/.app_version b/.app_version
index 6aec9e54..e756a90e 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.21.4
+0.21.5
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 5e2f006a..73b7cf9f 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -31,8 +31,6 @@ services:
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
- PHOTON_API_HOST: photon.komoot.io
- PHOTON_API_USE_HTTPS: true
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
diff --git a/.env.development b/.env.development
index 24313ecb..e083342f 100644
--- a/.env.development
+++ b/.env.development
@@ -4,5 +4,4 @@ DATABASE_PASSWORD=password
DATABASE_NAME=dawarich_development
DATABASE_PORT=5432
REDIS_URL=redis://localhost:6379/1
-PHOTON_API_HOST='photon.komoot.io'
DISTANCE_UNIT='km'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8962c791..737cf0c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,18 @@ 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.21.5 - 2025-01-07
+
+You may now use Geoapify API for reverse geocoding. To obrain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/).
+
+### Added
+
+- Geoapify API support for reverse geocoding. Provide `GEOAPIFY_API_KEY` env var to use it.
+
+### Removed
+
+- Photon ENV vars from the `.env.development` and docker-compose.yml files.
+
# 0.21.4 - 2025-01-05
### Fixed
diff --git a/app/jobs/reverse_geocoding_job.rb b/app/jobs/reverse_geocoding_job.rb
index dc49d2a2..d368720f 100644
--- a/app/jobs/reverse_geocoding_job.rb
+++ b/app/jobs/reverse_geocoding_job.rb
@@ -18,8 +18,8 @@ class ReverseGeocodingJob < ApplicationJob
end
def rate_limit_for_photon_api
- return unless PHOTON_API_HOST == 'photon.komoot.io'
+ return unless DawarichSettings.photon_enabled?
- sleep 1 if PHOTON_API_HOST == 'photon.komoot.io'
+ sleep 1 if DawarichSettings.photon_uses_komoot_io?
end
end
diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb
index 0ed4e236..9eec9de4 100644
--- a/app/services/reverse_geocoding/places/fetch_data.rb
+++ b/app/services/reverse_geocoding/places/fetch_data.rb
@@ -12,8 +12,8 @@ class ReverseGeocoding::Places::FetchData
end
def call
- if ::PHOTON_API_HOST.blank?
- Rails.logger.warn('PHOTON_API_HOST is not set')
+ unless DawarichSettings.reverse_geocoding_enabled?
+ Rails.logger.warn('Reverse geocoding is not enabled')
return
end
diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb
index bfa380b6..eef38298 100644
--- a/config/initializers/01_constants.rb
+++ b/config/initializers/01_constants.rb
@@ -1,11 +1,19 @@
# frozen_string_literal: true
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
+DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym
+
+APP_VERSION = File.read('.app_version').strip
+
+TELEMETRY_STRING = Base64.encode64('IjVFvb8j3P9-ArqhSGav9j8YcJaQiuNIzkfOPKQDk2lvKXqb8t1NSRv50oBkaKtlrB_ZRzO9NdurpMtncV_HYQ==')
+TELEMETRY_URL = 'https://influxdb2.frey.today/api/v2/write'
+
+# Reverse geocoding settings
REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true'
+
PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil)
PHOTON_API_KEY = ENV.fetch('PHOTON_API_KEY', nil)
PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true'
-DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym
-APP_VERSION = File.read('.app_version').strip
-TELEMETRY_STRING = Base64.encode64('IjVFvb8j3P9-ArqhSGav9j8YcJaQiuNIzkfOPKQDk2lvKXqb8t1NSRv50oBkaKtlrB_ZRzO9NdurpMtncV_HYQ==')
-TELEMETRY_URL = 'https://influxdb2.frey.today/api/v2/write'
+
+GEOAPIFY_API_KEY = ENV.fetch('GEOAPIFY_API_KEY', nil)
+# /Reverse geocoding settings
diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb
new file mode 100644
index 00000000..9d632067
--- /dev/null
+++ b/config/initializers/03_dawarich_settings.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class DawarichSettings
+ class << self
+ def reverse_geocoding_enabled?
+ photon_enabled? || geoapify_enabled?
+ end
+
+ def photon_enabled?
+ PHOTON_API_HOST.present?
+ end
+
+ def photon_uses_komoot_io?
+ PHOTON_API_HOST == 'photon.komoot.io'
+ end
+
+ def geoapify_enabled?
+ GEOAPIFY_API_KEY.present?
+ end
+ end
+end
diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb
index 837fb394..eb1a7fc4 100644
--- a/config/initializers/geocoder.rb
+++ b/config/initializers/geocoder.rb
@@ -12,11 +12,13 @@ settings = {
}
}
-if defined?(PHOTON_API_HOST)
+if PHOTON_API_HOST.present?
settings[:lookup] = :photon
settings[:photon] = { use_https: PHOTON_API_USE_HTTPS, host: PHOTON_API_HOST }
+ settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if defined?(PHOTON_API_KEY)
+elsif GEOAPIFY_API_KEY.present?
+ settings[:lookup] = :geoapify
+ settings[:api_key] = GEOAPIFY_API_KEY
end
-settings[:http_headers] = { 'X-Api-Key' => PHOTON_API_KEY } if defined?(PHOTON_API_KEY)
-
Geocoder.configure(settings)
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 9e54f2ab..d9dec786 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -27,4 +27,4 @@ Sidekiq.configure_client do |config|
config.redis = { url: ENV['REDIS_URL'] }
end
-Sidekiq::Queue['reverse_geocoding'].limit = 1 if Sidekiq.server? && PHOTON_API_HOST == 'photon.komoot.io'
+Sidekiq::Queue['reverse_geocoding'].limit = 1 if Sidekiq.server? && DawarichSettings.photon_uses_komoot_io?
diff --git a/docker-compose.yml b/docker-compose.yml
index fc46ae30..688f0ff2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -67,8 +67,6 @@ services:
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
- PHOTON_API_HOST: photon.komoot.io
- PHOTON_API_USE_HTTPS: true
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
@@ -122,8 +120,6 @@ services:
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
- PHOTON_API_HOST: photon.komoot.io
- PHOTON_API_USE_HTTPS: true
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: dawarich_app
PROMETHEUS_EXPORTER_PORT: 9394
diff --git a/docker-compose_mounted_volumes.yml b/docker-compose_mounted_volumes.yml
index e724aa88..e355cf59 100644
--- a/docker-compose_mounted_volumes.yml
+++ b/docker-compose_mounted_volumes.yml
@@ -45,8 +45,6 @@ services:
APPLICATION_HOSTS: localhost
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
- PHOTON_API_HOST: photon.komoot.io
- PHOTON_API_USE_HTTPS: true
stdin_open: true
tty: true
entrypoint: dev-entrypoint.sh
@@ -101,8 +99,6 @@ services:
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
- PHOTON_API_HOST: photon.komoot.io
- PHOTON_API_USE_HTTPS: true
stdin_open: true
tty: true
entrypoint: dev-entrypoint.sh
diff --git a/spec/lib/dawarich_settings_spec.rb b/spec/lib/dawarich_settings_spec.rb
new file mode 100644
index 00000000..304ac2de
--- /dev/null
+++ b/spec/lib/dawarich_settings_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe DawarichSettings do
+ describe '.reverse_geocoding_enabled?' do
+ context 'when photon is enabled' do
+ before do
+ allow(described_class).to receive(:photon_enabled?).and_return(true)
+ allow(described_class).to receive(:geoapify_enabled?).and_return(false)
+ end
+
+ it 'returns true' do
+ expect(described_class.reverse_geocoding_enabled?).to be true
+ end
+ end
+
+ context 'when geoapify is enabled' do
+ before do
+ allow(described_class).to receive(:photon_enabled?).and_return(false)
+ allow(described_class).to receive(:geoapify_enabled?).and_return(true)
+ end
+
+ it 'returns true' do
+ expect(described_class.reverse_geocoding_enabled?).to be true
+ end
+ end
+
+ context 'when neither service is enabled' do
+ before do
+ allow(described_class).to receive(:photon_enabled?).and_return(false)
+ allow(described_class).to receive(:geoapify_enabled?).and_return(false)
+ end
+
+ it 'returns false' do
+ expect(described_class.reverse_geocoding_enabled?).to be false
+ end
+ end
+ end
+
+ describe '.photon_enabled?' do
+ context 'when PHOTON_API_HOST is present' do
+ before { stub_const('PHOTON_API_HOST', 'photon.example.com') }
+
+ it 'returns true' do
+ expect(described_class.photon_enabled?).to be true
+ end
+ end
+
+ context 'when PHOTON_API_HOST is blank' do
+ before { stub_const('PHOTON_API_HOST', '') }
+
+ it 'returns false' do
+ expect(described_class.photon_enabled?).to be false
+ end
+ end
+ end
+
+ describe '.photon_uses_komoot_io?' do
+ context 'when PHOTON_API_HOST is komoot.io' do
+ before { stub_const('PHOTON_API_HOST', 'photon.komoot.io') }
+
+ it 'returns true' do
+ expect(described_class.photon_uses_komoot_io?).to be true
+ end
+ end
+
+ context 'when PHOTON_API_HOST is different' do
+ before { stub_const('PHOTON_API_HOST', 'photon.example.com') }
+
+ it 'returns false' do
+ expect(described_class.photon_uses_komoot_io?).to be false
+ end
+ end
+ end
+
+ describe '.geoapify_enabled?' do
+ context 'when GEOAPIFY_API_KEY is present' do
+ before { stub_const('GEOAPIFY_API_KEY', 'some-api-key') }
+
+ it 'returns true' do
+ expect(described_class.geoapify_enabled?).to be true
+ end
+ end
+
+ context 'when GEOAPIFY_API_KEY is blank' do
+ before { stub_const('GEOAPIFY_API_KEY', '') }
+
+ it 'returns false' do
+ expect(described_class.geoapify_enabled?).to be false
+ end
+ end
+ end
+end
From 688440710f037fa405e50a3d247fec034eac540d Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 7 Jan 2025 13:28:51 +0100
Subject: [PATCH 11/84] Remove APPLICATION_HOST env var.
---
.devcontainer/docker-compose.yml | 1 -
CHANGELOG.md | 3 ++-
config/environments/development.rb | 8 ++++----
docker-compose.yml | 2 --
docker-compose_mounted_volumes.yml | 2 --
docs/how_to_setup_reverse_proxy.md | 14 ++++++--------
6 files changed, 12 insertions(+), 18 deletions(-)
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 73b7cf9f..e0bc7867 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -26,7 +26,6 @@ services:
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
MIN_MINUTES_SPENT_IN_CITY: 60
- APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 737cf0c7..d1952895 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.21.5 - 2025-01-07
-You may now use Geoapify API for reverse geocoding. To obrain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/).
+You may now use Geoapify API for reverse geocoding. To obtain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/).
### Added
@@ -16,6 +16,7 @@ You may now use Geoapify API for reverse geocoding. To obrain an API key, sign u
### Removed
- Photon ENV vars from the `.env.development` and docker-compose.yml files.
+- `APPLICATION_HOST` env var.
# 0.21.4 - 2025-01-05
diff --git a/config/environments/development.rb b/config/environments/development.rb
index dd27f7bd..29b9a038 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -86,11 +86,11 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
- config.action_mailer.default_url_options = { host: ENV.fetch('APPLICATION_HOST', 'localhost'), port: 3000 }
- config.hosts << ENV.fetch('APPLICATION_HOST', 'localhost')
- hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost')
- config.hosts.concat(hosts.split(',')) if hosts.present?
+ hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
+
+ config.action_mailer.default_url_options = { host: hosts.first, port: 3000 }
+ config.hosts.concat(hosts) if hosts.present?
config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https'
diff --git a/docker-compose.yml b/docker-compose.yml
index 688f0ff2..53586a39 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -62,7 +62,6 @@ services:
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
MIN_MINUTES_SPENT_IN_CITY: 60
- APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
@@ -115,7 +114,6 @@ services:
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
- APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
diff --git a/docker-compose_mounted_volumes.yml b/docker-compose_mounted_volumes.yml
index e355cf59..09fe07d8 100644
--- a/docker-compose_mounted_volumes.yml
+++ b/docker-compose_mounted_volumes.yml
@@ -41,7 +41,6 @@ services:
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
MIN_MINUTES_SPENT_IN_CITY: 60
- APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
@@ -94,7 +93,6 @@ services:
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
- APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
diff --git a/docs/how_to_setup_reverse_proxy.md b/docs/how_to_setup_reverse_proxy.md
index 30cb691b..efaddd2d 100644
--- a/docs/how_to_setup_reverse_proxy.md
+++ b/docs/how_to_setup_reverse_proxy.md
@@ -14,7 +14,6 @@ dawarich_app:
...
environment:
...
- APPLICATION_HOST: "yourhost.com" <------------------------------ Edit this
APPLICATION_HOSTS: "yourhost.com,www.yourhost.com,127.0.0.1" <-- Edit this
```
@@ -25,7 +24,6 @@ dawarich_sidekiq:
...
environment:
...
- APPLICATION_HOST: "yourhost.com" <------------------------------ Edit this
APPLICATION_HOSTS: "yourhost.com,www.yourhost.com,127.0.0.1" <-- Edit this
...
```
@@ -48,7 +46,7 @@ server {
brotli on;
brotli_comp_level 6;
- brotli_types
+ brotli_types
text/css
text/plain
text/xml
@@ -106,24 +104,24 @@ With the above commands entered, the configuration below should work properly.
```apache
ServerName example.com
-
+
ProxyRequests Off
ProxyPreserveHost On
-
+
Require all granted
-
+
Header always set X-Real-IP %{REMOTE_ADDR}s
Header always set X-Forwarded-For %{REMOTE_ADDR}s
Header always set X-Forwarded-Proto https
Header always set X-Forwarded-Server %{SERVER_NAME}s
Header always set Host %{HTTP_HOST}s
-
+
SetOutputFilter BROTLI
AddOutputFilterByType BROTLI_COMPRESS text/css text/plain text/xml text/javascript application/javascript application/json application/manifest+json application/vnd.api+json application/xml application/xhtml+xml application/rss+xml application/atom+xml application/vnd.ms-fontobject application/x-font-ttf application/x-font-opentype application/x-font-truetype image/svg+xml image/x-icon image/vnd.microsoft.icon font/ttf font/eot font/otf font/opentype
BrotliCompressionQuality 6
-
+
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
From 974f45a4c91ebedfcf730355a006999e75f34f42 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 7 Jan 2025 13:41:09 +0100
Subject: [PATCH 12/84] Remove REVERSE_GEOCODING_ENABLED env var
---
CHANGELOG.md | 1 +
app/jobs/reverse_geocoding_job.rb | 2 +-
app/models/place.rb | 2 +-
app/models/point.rb | 2 +-
app/models/visit.rb | 2 +-
app/services/visits/suggest.rb | 6 +-----
app/views/stats/_stat.html.erb | 2 +-
app/views/stats/index.html.erb | 4 ++--
config/initializers/01_constants.rb | 2 --
config/initializers/03_dawarich_settings.rb | 8 ++++----
spec/jobs/reverse_geocoding_job_spec.rb | 8 ++++----
spec/lib/dawarich_settings_spec.rb | 6 ++++++
spec/services/visits/suggest_spec.rb | 5 ++---
13 files changed, 25 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1952895..f5e7be49 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ You may now use Geoapify API for reverse geocoding. To obtain an API key, sign u
- Photon ENV vars from the `.env.development` and docker-compose.yml files.
- `APPLICATION_HOST` env var.
+- `REVERSE_GEOCODING_ENABLED` env var.
# 0.21.4 - 2025-01-05
diff --git a/app/jobs/reverse_geocoding_job.rb b/app/jobs/reverse_geocoding_job.rb
index d368720f..8c2a232b 100644
--- a/app/jobs/reverse_geocoding_job.rb
+++ b/app/jobs/reverse_geocoding_job.rb
@@ -4,7 +4,7 @@ class ReverseGeocodingJob < ApplicationJob
queue_as :reverse_geocoding
def perform(klass, id)
- return unless REVERSE_GEOCODING_ENABLED
+ return unless DawarichSettings.reverse_geocoding_enabled?
rate_limit_for_photon_api
diff --git a/app/models/place.rb b/app/models/place.rb
index a4ff8970..2ed0aa2d 100644
--- a/app/models/place.rb
+++ b/app/models/place.rb
@@ -13,7 +13,7 @@ class Place < ApplicationRecord
enum :source, { manual: 0, photon: 1 }
def async_reverse_geocode
- return unless REVERSE_GEOCODING_ENABLED
+ return unless DawarichSettings.reverse_geocoding_enabled?
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
diff --git a/app/models/point.rb b/app/models/point.rb
index bec501b4..040e6d41 100644
--- a/app/models/point.rb
+++ b/app/models/point.rb
@@ -34,7 +34,7 @@ class Point < ApplicationRecord
end
def async_reverse_geocode
- return unless REVERSE_GEOCODING_ENABLED
+ return unless DawarichSettings.reverse_geocoding_enabled?
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
diff --git a/app/models/visit.rb b/app/models/visit.rb
index ddd6124f..f46d219b 100644
--- a/app/models/visit.rb
+++ b/app/models/visit.rb
@@ -40,7 +40,7 @@ class Visit < ApplicationRecord
end
def async_reverse_geocode
- return unless REVERSE_GEOCODING_ENABLED
+ return unless DawarichSettings.reverse_geocoding_enabled?
return if place.blank?
ReverseGeocodingJob.perform_later('place', place_id)
diff --git a/app/services/visits/suggest.rb b/app/services/visits/suggest.rb
index f68bffce..4d02a45c 100644
--- a/app/services/visits/suggest.rb
+++ b/app/services/visits/suggest.rb
@@ -20,7 +20,7 @@ class Visits::Suggest
create_visits_notification(user)
- return nil unless reverse_geocoding_enabled?
+ return nil unless DawarichSettings.reverse_geocoding_enabled?
reverse_geocode(visits)
end
@@ -68,10 +68,6 @@ class Visits::Suggest
visits.each(&:async_reverse_geocode)
end
- def reverse_geocoding_enabled?
- ::REVERSE_GEOCODING_ENABLED && ::PHOTON_API_HOST.present?
- end
-
def create_visits_notification(user)
content = <<~CONTENT
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the Visits page.
diff --git a/app/views/stats/_stat.html.erb b/app/views/stats/_stat.html.erb
index f8e59e04..8ac892f2 100644
--- a/app/views/stats/_stat.html.erb
+++ b/app/views/stats/_stat.html.erb
@@ -12,7 +12,7 @@
<%= stat.distance %><%= DISTANCE_UNIT %>
- <% if REVERSE_GEOCODING_ENABLED %>
+ <% if DawarichSettings.reverse_geocoding_enabled? %>
diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb
index eef38298..ce760238 100644
--- a/config/initializers/01_constants.rb
+++ b/config/initializers/01_constants.rb
@@ -9,8 +9,6 @@ TELEMETRY_STRING = Base64.encode64('IjVFvb8j3P9-ArqhSGav9j8YcJaQiuNIzkfOPKQDk2lv
TELEMETRY_URL = 'https://influxdb2.frey.today/api/v2/write'
# Reverse geocoding settings
-REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true'
-
PHOTON_API_HOST = ENV.fetch('PHOTON_API_HOST', nil)
PHOTON_API_KEY = ENV.fetch('PHOTON_API_KEY', nil)
PHOTON_API_USE_HTTPS = ENV.fetch('PHOTON_API_USE_HTTPS', 'true') == 'true'
diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb
index 9d632067..87cf4817 100644
--- a/config/initializers/03_dawarich_settings.rb
+++ b/config/initializers/03_dawarich_settings.rb
@@ -3,19 +3,19 @@
class DawarichSettings
class << self
def reverse_geocoding_enabled?
- photon_enabled? || geoapify_enabled?
+ @reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled?
end
def photon_enabled?
- PHOTON_API_HOST.present?
+ @photon_enabled ||= PHOTON_API_HOST.present?
end
def photon_uses_komoot_io?
- PHOTON_API_HOST == 'photon.komoot.io'
+ @photon_uses_komoot_io ||= PHOTON_API_HOST == 'photon.komoot.io'
end
def geoapify_enabled?
- GEOAPIFY_API_KEY.present?
+ @geoapify_enabled ||= GEOAPIFY_API_KEY.present?
end
end
end
diff --git a/spec/jobs/reverse_geocoding_job_spec.rb b/spec/jobs/reverse_geocoding_job_spec.rb
index dfd3da8e..b6420be0 100644
--- a/spec/jobs/reverse_geocoding_job_spec.rb
+++ b/spec/jobs/reverse_geocoding_job_spec.rb
@@ -12,8 +12,8 @@ RSpec.describe ReverseGeocodingJob, type: :job do
allow(Geocoder).to receive(:search).and_return([double(city: 'City', country: 'Country')])
end
- context 'when REVERSE_GEOCODING_ENABLED is false' do
- before { stub_const('REVERSE_GEOCODING_ENABLED', false) }
+ context 'when reverse geocoding is disabled' do
+ before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false) }
it 'does not update point' do
expect { perform }.not_to(change { point.reload.city })
@@ -28,8 +28,8 @@ RSpec.describe ReverseGeocodingJob, type: :job do
end
end
- context 'when REVERSE_GEOCODING_ENABLED is true' do
- before { stub_const('REVERSE_GEOCODING_ENABLED', true) }
+ context 'when reverse geocoding is enabled' do
+ before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
let(:stubbed_geocoder) { OpenStruct.new(data: { city: 'City', country: 'Country' }) }
diff --git a/spec/lib/dawarich_settings_spec.rb b/spec/lib/dawarich_settings_spec.rb
index 304ac2de..bce61846 100644
--- a/spec/lib/dawarich_settings_spec.rb
+++ b/spec/lib/dawarich_settings_spec.rb
@@ -3,6 +3,12 @@
require 'rails_helper'
RSpec.describe DawarichSettings do
+ before do
+ described_class.instance_variables.each do |ivar|
+ described_class.remove_instance_variable(ivar)
+ end
+ end
+
describe '.reverse_geocoding_enabled?' do
context 'when photon is enabled' do
before do
diff --git a/spec/services/visits/suggest_spec.rb b/spec/services/visits/suggest_spec.rb
index 00c20c81..31d76b2e 100644
--- a/spec/services/visits/suggest_spec.rb
+++ b/spec/services/visits/suggest_spec.rb
@@ -44,8 +44,7 @@ RSpec.describe Visits::Suggest do
context 'when reverse geocoding is enabled' do
before do
- stub_const('REVERSE_GEOCODING_ENABLED', true)
- stub_const('PHOTON_API_HOST', 'http://localhost:2323')
+ allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
end
it 'reverse geocodes visits' do
@@ -57,7 +56,7 @@ RSpec.describe Visits::Suggest do
context 'when reverse geocoding is disabled' do
before do
- stub_const('REVERSE_GEOCODING_ENABLED', false)
+ allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false)
end
it 'does not reverse geocode visits' do
From 1229b4121c33670fa8d9b37e3b102141c5050245 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 7 Jan 2025 14:07:33 +0100
Subject: [PATCH 13/84] Update specs for reverse geocoding
---
spec/models/place_spec.rb | 2 ++
spec/models/point_spec.rb | 2 ++
spec/services/jobs/create_spec.rb | 2 ++
3 files changed, 6 insertions(+)
diff --git a/spec/models/place_spec.rb b/spec/models/place_spec.rb
index 3a7bcd21..48722d9c 100644
--- a/spec/models/place_spec.rb
+++ b/spec/models/place_spec.rb
@@ -23,6 +23,8 @@ RSpec.describe Place, type: :model do
describe '#async_reverse_geocode' do
let(:place) { create(:place) }
+ before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
+
it 'updates address' do
expect { place.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob).with('Place', place.id)
end
diff --git a/spec/models/point_spec.rb b/spec/models/point_spec.rb
index ece3ea71..c1972838 100644
--- a/spec/models/point_spec.rb
+++ b/spec/models/point_spec.rb
@@ -46,6 +46,8 @@ RSpec.describe Point, type: :model do
describe '#async_reverse_geocode' do
let(:point) { build(:point) }
+ before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
+
it 'enqueues ReverseGeocodeJob with correct arguments' do
point.save
diff --git a/spec/services/jobs/create_spec.rb b/spec/services/jobs/create_spec.rb
index fb53e848..cc482b67 100644
--- a/spec/services/jobs/create_spec.rb
+++ b/spec/services/jobs/create_spec.rb
@@ -4,6 +4,8 @@ require 'rails_helper'
RSpec.describe Jobs::Create do
describe '#call' do
+ before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
+
context 'when job_name is start_reverse_geocoding' do
let(:user) { create(:user) }
let(:points) { create_list(:point, 4, user:) }
From 73fc9be3fb700858c08ef998493bed3d5bd722fb Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 7 Jan 2025 14:31:06 +0100
Subject: [PATCH 14/84] Fix inconsistent password for the `dawarich_db` service
in `docker-compose_mounted_volumes.yml`.
---
.app_version | 2 +-
CHANGELOG.md | 7 +++++++
app/assets/builds/tailwind.css | 6 +++---
app/javascript/controllers/trip_map_controller.js | 3 ++-
app/javascript/controllers/trips_controller.js | 12 ++++++++++--
.../controllers/visit_modal_map_controller.js | 10 +++++++++-
app/javascript/maps/markers.js | 8 ++++++--
app/javascript/maps/polylines.js | 3 ++-
docker-compose_mounted_volumes.yml | 2 +-
9 files changed, 41 insertions(+), 12 deletions(-)
diff --git a/.app_version b/.app_version
index e756a90e..78cfa5eb 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.21.5
+0.21.6
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5e7be49..a1f8706b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ 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.21.6 - 2025-01-07
+
+### Fixed
+
+- Inconsistent password for the `dawarich_db` service in `docker-compose_mounted_volumes.yml`. #605
+- Points are now being rendered with higher z-index than polylines. #577
+
# 0.21.5 - 2025-01-07
You may now use Geoapify API for reverse geocoding. To obtain an API key, sign up at https://myprojects.geoapify.com/ and create a new project. Make sure you have read and understood the [pricing policy](https://www.geoapify.com/pricing) and [Terms and Conditions](https://www.geoapify.com/terms-and-conditions/).
diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css
index e27405e3..e91912cd 100644
--- a/app/assets/builds/tailwind.css
+++ b/app/assets/builds/tailwind.css
@@ -1,6 +1,6 @@
-*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.mockup-code{border-radius:var(--rounded-box,1rem);min-width:18rem;overflow:hidden;overflow-x:auto;position:relative;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-bottom:1.25rem;padding-top:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;opacity:.5;text-align:right;width:2rem}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.radio,.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer}.select{border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{border-color:transparent;border-width:var(--tab-border,0);display:none;grid-column-end:span 9999;grid-column-start:1;grid-row-start:2;margin-top:calc(var(--tab-border)*-1)}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var(
+*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}.footer-center{text-align:center}.footer-center,.footer-center>*{place-items:center}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.mockup-code{border-radius:var(--rounded-box,1rem);min-width:18rem;overflow:hidden;overflow-x:auto;position:relative;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));padding-bottom:1.25rem;padding-top:1.25rem;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));direction:ltr}.mockup-code pre[data-prefix]:before{content:attr(data-prefix);display:inline-block;opacity:.5;text-align:right;width:2rem}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{border-color:transparent;border-width:var(--tab-border,0);display:none;grid-column-end:span 9999;grid-column-start:1;grid-row-start:2;margin-top:calc(var(--tab-border)*-1)}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var(
--timeline-col-end,minmax(0,1fr)
);grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var(
--timeline-row-end,minmax(0,1fr)
- );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--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)))}.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>* .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.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.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.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}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link: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")}: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-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@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: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::-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: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}}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.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}.my-8{margin-bottom:2rem;margin-top:2rem}.mb-10{margin-bottom:2.5rem}.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-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.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-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.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-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-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-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-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)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity)))}.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}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-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}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.blur{--tw-blur:blur(8px)}.blur,.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-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}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact
-.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\: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-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:mt-0{margin-top:0}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-1\/6,.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:w-5\/6{width:83.333333%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}
\ No newline at end of file
+ );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{--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)))}.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.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.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.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}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link: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-md{width:1.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-code:before{border-radius:9999px;box-shadow:1.4em 0,2.8em 0,4.2em 0;content:"";display:block;height:.75rem;margin-bottom:1rem;opacity:.3;width:.75rem}.mockup-code pre{padding-right:1.25rem}.mockup-code pre:before{content:"";margin-right:2ch}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@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: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::-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:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.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}.my-8{margin-bottom:2rem;margin-top:2rem}.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-2{margin-left:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.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-32{height:8rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-48{width:12rem}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-8{width:2rem}.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-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-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-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)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-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-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-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}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.opacity-0{opacity:0}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.blur{--tw-blur:blur(8px)}.blur,.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-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}@tailwind daisyui;@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact
+.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\: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-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-end{align-items:flex-end}.sm\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.sm\:space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(0px*var(--tw-space-y-reverse));margin-top:calc(0px*(1 - var(--tw-space-y-reverse)))}}@media (min-width:768px){.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:w-3\/12{width:25%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}
\ No newline at end of file
diff --git a/app/javascript/controllers/trip_map_controller.js b/app/javascript/controllers/trip_map_controller.js
index b7df8943..b2a18bfb 100644
--- a/app/javascript/controllers/trip_map_controller.js
+++ b/app/javascript/controllers/trip_map_controller.js
@@ -43,8 +43,9 @@ export default class extends Controller {
const polyline = L.polyline(points, {
color: 'blue',
+ opacity: 0.8,
weight: 3,
- opacity: 0.8
+ zIndexOffset: 400
}).addTo(this.map)
this.map.fitBounds(polyline.getBounds(), {
diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js
index 497fe5e3..602c04be 100644
--- a/app/javascript/controllers/trips_controller.js
+++ b/app/javascript/controllers/trips_controller.js
@@ -138,7 +138,14 @@ export default class extends Controller {
addMarkers() {
this.coordinates.forEach(coord => {
- const marker = L.circleMarker([coord[0], coord[1]], {radius: 4})
+ const marker = L.circleMarker(
+ [coord[0], coord[1]],
+ {
+ radius: 4,
+ color: coord[5] < 0 ? "orange" : "blue",
+ zIndexOffset: 1000
+ }
+ )
const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit)
marker.bindPopup(popupContent)
@@ -152,8 +159,9 @@ export default class extends Controller {
const points = this.coordinates.map(coord => [coord[0], coord[1]])
const polyline = L.polyline(points, {
color: 'blue',
+ opacity: 0.8,
weight: 3,
- opacity: 0.8
+ zIndexOffset: 400
})
// Add to polylines layer instead of directly to map
this.polylinesLayer.addTo(this.map)
diff --git a/app/javascript/controllers/visit_modal_map_controller.js b/app/javascript/controllers/visit_modal_map_controller.js
index f8f4c9ea..5fcb0547 100644
--- a/app/javascript/controllers/visit_modal_map_controller.js
+++ b/app/javascript/controllers/visit_modal_map_controller.js
@@ -27,7 +27,15 @@ export default class extends Controller {
addMarkers() {
this.coordinates.forEach((coordinate) => {
- L.circleMarker([coordinate[0], coordinate[1]], { radius: 4 }).addTo(this.map);
+ L.circleMarker(
+ [coordinate[0], coordinate[1]],
+ {
+ radius: 4,
+ color: coordinate[5] < 0 ? "orange" : "blue",
+ zIndexOffset: 1000
+ }
+ ).addTo(this.map);
});
}
}
+
diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js
index 6b506285..1477f8c2 100644
--- a/app/javascript/maps/markers.js
+++ b/app/javascript/maps/markers.js
@@ -12,7 +12,8 @@ export function createMarkersArray(markersData, userSettings) {
return L.circleMarker([lat, lon], {
radius: 4,
color: markerColor,
- zIndexOffset: 1000
+ zIndexOffset: 1000,
+ pane: 'markerPane'
}).bindPopup(popupContent, { autoClose: false });
});
}
@@ -47,6 +48,9 @@ export function createSimplifiedMarkers(markersData) {
const [lat, lon] = marker;
const popupContent = createPopupContent(marker);
let markerColor = marker[5] < 0 ? "orange" : "blue";
- return L.circleMarker([lat, lon], { radius: 4, color: markerColor }).bindPopup(popupContent);
+ return L.circleMarker(
+ [lat, lon],
+ { radius: 4, color: markerColor, zIndexOffset: 1000 }
+ ).bindPopup(popupContent);
});
}
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index 92f4ea27..2c09022d 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -126,7 +126,8 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
color: "blue",
opacity: 0.6,
weight: 3,
- zIndexOffset: 400
+ zIndexOffset: 400,
+ pane: 'overlayPane'
});
addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit);
diff --git a/docker-compose_mounted_volumes.yml b/docker-compose_mounted_volumes.yml
index 09fe07d8..6a712834 100644
--- a/docker-compose_mounted_volumes.yml
+++ b/docker-compose_mounted_volumes.yml
@@ -152,7 +152,7 @@ services:
- dawarich
environment:
POSTGRES_USER: postgres
- POSTGRES_PASSWORD: eJH3YZsVc2s6byhFwpEny
+ POSTGRES_PASSWORD: password
POSTGRES_DATABASE: dawarich
volumes:
- ./db:/var/lib/postgresql/data
From e904d396c8ae3e52be87d9935d7ea5bf187d6c11 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 7 Jan 2025 15:02:35 +0100
Subject: [PATCH 15/84] Make sure cache jobs are run only on server start
---
CHANGELOG.md | 6 ++++++
app/jobs/cache/preheating_job.rb | 6 +++++-
app/models/user.rb | 24 ++++++++++++++++--------
app/services/cache/clean.rb | 5 +++++
app/services/imports/create.rb | 2 +-
config/environment.rb | 11 +++++++----
spec/services/imports/create_spec.rb | 4 ++--
7 files changed, 42 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1f8706b..3b2e1169 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.21.6 - 2025-01-07
+### Changed
+
+- Disabled visit suggesting job after import.
+- Improved performance of the `User#years_tracked` method.
+
### Fixed
- Inconsistent password for the `dawarich_db` service in `docker-compose_mounted_volumes.yml`. #605
- Points are now being rendered with higher z-index than polylines. #577
+- Run cache cleaning and preheating jobs only on server start. #594
# 0.21.5 - 2025-01-07
diff --git a/app/jobs/cache/preheating_job.rb b/app/jobs/cache/preheating_job.rb
index bdf3ea99..c43a50b3 100644
--- a/app/jobs/cache/preheating_job.rb
+++ b/app/jobs/cache/preheating_job.rb
@@ -5,7 +5,11 @@ class Cache::PreheatingJob < ApplicationJob
def perform
User.find_each do |user|
- Rails.cache.write("dawarich/user_#{user.id}_years_tracked", user.years_tracked, expires_in: 1.day)
+ Rails.cache.write(
+ "dawarich/user_#{user.id}_years_tracked",
+ user.years_tracked,
+ expires_in: 1.day
+ )
end
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 64e45425..f0413f68 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -66,15 +66,23 @@ class User < ApplicationRecord
def years_tracked
Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do
- tracked_points
- .pluck(:timestamp)
- .map { |ts| Time.zone.at(ts) }
- .group_by(&:year)
- .transform_values do |dates|
- dates.map { |date| date.strftime('%b') }.uniq.sort
- end
+ # Use select_all for better performance with large datasets
+ sql = <<-SQL
+ SELECT DISTINCT
+ EXTRACT(YEAR FROM TO_TIMESTAMP(timestamp)) AS year,
+ TO_CHAR(TO_TIMESTAMP(timestamp), 'Mon') AS month
+ FROM points
+ WHERE user_id = #{id}
+ ORDER BY year DESC, month ASC
+ SQL
+
+ result = ActiveRecord::Base.connection.select_all(sql)
+
+ result
+ .map { |r| [r['year'].to_i, r['month']] }
+ .group_by { |year, _| year }
+ .transform_values { |year_data| year_data.map { |_, month| month } }
.map { |year, months| { year: year, months: months } }
- .sort_by { |entry| -entry[:year] } # Sort in descending order
end
end
diff --git a/app/services/cache/clean.rb b/app/services/cache/clean.rb
index 46a69e0b..15647b99 100644
--- a/app/services/cache/clean.rb
+++ b/app/services/cache/clean.rb
@@ -4,6 +4,7 @@ class Cache::Clean
class << self
def call
Rails.logger.info('Cleaning cache...')
+ delete_control_flag
delete_version_cache
delete_years_tracked_cache
Rails.logger.info('Cache cleaned')
@@ -11,6 +12,10 @@ class Cache::Clean
private
+ def delete_control_flag
+ Rails.cache.delete('cache_jobs_scheduled')
+ end
+
def delete_version_cache
Rails.cache.delete(CheckAppVersion::VERSION_CACHE_KEY)
end
diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb
index af9b0d0c..16374170 100644
--- a/app/services/imports/create.rb
+++ b/app/services/imports/create.rb
@@ -14,7 +14,7 @@ class Imports::Create
create_import_finished_notification(import, user)
schedule_stats_creating(user.id)
- schedule_visit_suggesting(user.id, import)
+ # schedule_visit_suggesting(user.id, import) # Disabled until places & visits are reworked
rescue StandardError => e
create_import_failed_notification(import, user, e)
end
diff --git a/config/environment.rb b/config/environment.rb
index 7e5c58f9..9d36606c 100644
--- a/config/environment.rb
+++ b/config/environment.rb
@@ -6,8 +6,11 @@ require_relative 'application'
# Initialize the Rails application.
Rails.application.initialize!
-# Clear the cache
-Cache::CleaningJob.perform_later
+# Use an atomic operation to ensure one-time execution
+if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true)
+ # Clear the cache
+ Cache::CleaningJob.perform_later
-# Preheat the cache
-Cache::PreheatingJob.perform_later
+ # Preheat the cache
+ Cache::PreheatingJob.perform_later
+end
diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb
index 85f2131a..08b1c60d 100644
--- a/spec/services/imports/create_spec.rb
+++ b/spec/services/imports/create_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe Imports::Create do
end
end
- it 'schedules visit suggesting' do
+ xit 'schedules visit suggesting' do
Sidekiq::Testing.inline! do
expect { service.call }.to have_enqueued_job(VisitSuggestingJob)
end
@@ -59,7 +59,7 @@ RSpec.describe Imports::Create do
context 'when import fails' do
before do
- allow(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_return(double(call: false))
+ allow(OwnTracks::ExportParser).to receive(:new).with(import, user.id).and_raise(StandardError)
end
it 'creates a failed notification' do
From 0625a4fa6439224e9ef67346cfdac6463a3d4697 Mon Sep 17 00:00:00 2001
From: Evgenii Burmakin
Date: Tue, 7 Jan 2025 15:36:44 +0100
Subject: [PATCH 16/84] Update issue templates
---
.github/ISSUE_TEMPLATE/bug_report.md | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 2de485cd..2256378e 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -7,11 +7,14 @@ assignees: ''
---
-**Describe the bug**
-A clear and concise description of what the bug is.
+**OS & Hardware**
+Provide your software and hardware specs
**Version**
-Include version of Dawarich you're experiencing problem on.
+Provide the version of Dawarich you're experiencing the problem on.
+
+**Describe the bug**
+A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
From 1476816418635c2e568ee299523bfd2172160b7c Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Tue, 7 Jan 2025 16:04:03 +0100
Subject: [PATCH 17/84] Update production environment
---
config/environments/production.rb | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 8484021a..4e8f5661 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -103,8 +103,8 @@ Rails.application.configure do
# ]
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
- config.hosts << ENV.fetch('APPLICATION_HOST', 'localhost')
+ hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
- hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost')
- config.hosts.concat(hosts.split(',')) if hosts.present?
+ config.action_mailer.default_url_options = { host: hosts.first, port: 3000 }
+ config.hosts.concat(hosts) if hosts.present?
end
From ba40b7d284fcdcfe515ca18e7c9bcc997b482a80 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 8 Jan 2025 13:06:50 +0100
Subject: [PATCH 18/84] Implement production environment
---
.github/workflows/build_and_push.yml | 2 +-
dev-docker-sidekiq-entrypoint.sh | 21 -----
.dockerignore => docker/.dockerignore | 0
Dockerfile => docker/Dockerfile | 8 +-
Dockerfile.dev => docker/Dockerfile.dev | 0
.../dev-docker-entrypoint.sh | 6 +-
.../docker-compose.production.yml | 23 +++--
.../docker-compose.yml | 0
.../docker-compose_mounted_volumes.yml | 0
docker/prod-docker-entrypoint.sh | 93 +++++++++++++++++++
prod-docker-entrypoint.sh | 51 ----------
11 files changed, 115 insertions(+), 89 deletions(-)
delete mode 100644 dev-docker-sidekiq-entrypoint.sh
rename .dockerignore => docker/.dockerignore (100%)
rename Dockerfile => docker/Dockerfile (83%)
rename Dockerfile.dev => docker/Dockerfile.dev (100%)
rename dev-docker-entrypoint.sh => docker/dev-docker-entrypoint.sh (92%)
rename docker-compose.production.yml => docker/docker-compose.production.yml (89%)
rename docker-compose.yml => docker/docker-compose.yml (100%)
rename docker-compose_mounted_volumes.yml => docker/docker-compose_mounted_volumes.yml (100%)
create mode 100644 docker/prod-docker-entrypoint.sh
delete mode 100644 prod-docker-entrypoint.sh
diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml
index 2c1ebe4c..05173d53 100644
--- a/.github/workflows/build_and_push.yml
+++ b/.github/workflows/build_and_push.yml
@@ -40,7 +40,7 @@ jobs:
uses: docker/build-push-action@v2
with:
context: .
- file: ./Dockerfile
+ file: ./docker/Dockerfile
push: true
tags: freikin/dawarich:latest,freikin/dawarich:${{ github.event.inputs.branch || github.ref_name }}
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
diff --git a/dev-docker-sidekiq-entrypoint.sh b/dev-docker-sidekiq-entrypoint.sh
deleted file mode 100644
index db7c87e9..00000000
--- a/dev-docker-sidekiq-entrypoint.sh
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/bin/sh
-
-set -e
-
-echo "Environment: $RAILS_ENV"
-
-# set env var defaults
-DATABASE_HOST=${DATABASE_HOST:-"dawarich_db"}
-DATABASE_PORT=${DATABASE_PORT:-5432}
-DATABASE_USER=${DATABASE_USER:-"postgres"}
-DATABASE_PASSWORD=${DATABASE_PASSWORD:-"password"}
-DATABASE_NAME=${DATABASE_NAME:-"dawarich_development"}
-
-# Wait for the database to be ready
-until nc -zv $DATABASE_HOST ${DATABASE_PORT:-5432}; do
- echo "Waiting for PostgreSQL to be ready..."
- sleep 1
-done
-
-# run passed commands
-bundle exec ${@}
diff --git a/.dockerignore b/docker/.dockerignore
similarity index 100%
rename from .dockerignore
rename to docker/.dockerignore
diff --git a/Dockerfile b/docker/Dockerfile
similarity index 83%
rename from Dockerfile
rename to docker/Dockerfile
index ef2b74c5..07334a1f 100644
--- a/Dockerfile
+++ b/docker/Dockerfile
@@ -32,17 +32,17 @@ RUN gem install bundler --version "$BUNDLE_VERSION" \
# Navigate to app directory
WORKDIR $APP_PATH
-COPY Gemfile Gemfile.lock vendor .ruby-version ./
+COPY ../Gemfile ../Gemfile.lock ../vendor ../.ruby-version ./
# Install missing gems
RUN bundle config set --local path 'vendor/bundle' \
&& bundle install --jobs 20 --retry 5
-COPY . ./
+COPY ../. ./
# Copy entrypoint scripts and grant execution permissions
-COPY ./dev-docker-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
-RUN chmod +x /usr/local/bin/dev-entrypoint.sh
+COPY ./docker/prod-docker-entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE $RAILS_PORT
diff --git a/Dockerfile.dev b/docker/Dockerfile.dev
similarity index 100%
rename from Dockerfile.dev
rename to docker/Dockerfile.dev
diff --git a/dev-docker-entrypoint.sh b/docker/dev-docker-entrypoint.sh
similarity index 92%
rename from dev-docker-entrypoint.sh
rename to docker/dev-docker-entrypoint.sh
index 385b50da..90a8ddfd 100644
--- a/dev-docker-entrypoint.sh
+++ b/docker/dev-docker-entrypoint.sh
@@ -24,8 +24,8 @@ until nc -zv $DATABASE_HOST ${DATABASE_PORT:-5432}; do
done
# Install gems
-gem update --system 3.5.7
-gem install bundler --version '2.5.9'
+gem update --system 3.6.2
+gem install bundler --version '2.5.21'
# Create the database
if [ "$(psql "postgres://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT" -XtAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'")" = '1' ]; then
@@ -37,7 +37,7 @@ fi
# Run database migrations
echo "PostgreSQL is ready. Running database migrations..."
-bundle exec rails db:prepare
+bundle exec rails db:migrate
# Run data migrations
echo "Running DATA migrations..."
diff --git a/docker-compose.production.yml b/docker/docker-compose.production.yml
similarity index 89%
rename from docker-compose.production.yml
rename to docker/docker-compose.production.yml
index e6a39b90..2399e731 100644
--- a/docker-compose.production.yml
+++ b/docker/docker-compose.production.yml
@@ -2,7 +2,7 @@ networks:
dawarich:
services:
dawarich_redis:
- image: redis:7.0-alpine
+ image: redis:7.4-alpine
container_name: dawarich_redis
command: redis-server
networks:
@@ -17,7 +17,7 @@ services:
start_period: 30s
timeout: 10s
dawarich_db:
- image: postgres:14.2-alpine
+ image: postgres:17-alpine
container_name: dawarich_db
volumes:
- db_data:/var/lib/postgresql/data
@@ -27,15 +27,16 @@ services:
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
+ POSTGRES_DB: dawarich_production
restart: always
healthcheck:
- test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
+ test: [ "CMD", "pg_isready", "-U", "postgres" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
dawarich_app:
- image: freikin/dawarich:latest
+ image: dawarich:prod
container_name: dawarich_app
volumes:
- gem_cache:/usr/local/bundle/gems_app
@@ -48,16 +49,17 @@ services:
# - 9394:9394 # Prometheus exporter, uncomment if needed
stdin_open: true
tty: true
- entrypoint: dev-entrypoint.sh
+ entrypoint: entrypoint.sh
command: ['bin/dev']
restart: on-failure
environment:
RAILS_ENV: production
REDIS_URL: redis://dawarich_redis:6379/0
DATABASE_HOST: dawarich_db
+ DATABASE_PORT: 5432
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
- DATABASE_NAME: dawarich_development
+ DATABASE_NAME: dawarich_production
MIN_MINUTES_SPENT_IN_CITY: 60
APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
@@ -69,6 +71,8 @@ services:
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
+ SECRET_KEY_BASE: 1234567890
+ RAILS_LOG_TO_STDOUT: "true"
logging:
driver: "json-file"
options:
@@ -93,7 +97,7 @@ services:
cpus: '0.50' # Limit CPU usage to 50% of one core
memory: '2G' # Limit memory usage to 2GB
dawarich_sidekiq:
- image: freikin/dawarich:latest
+ image: dawarich:prod
container_name: dawarich_sidekiq
volumes:
- gem_cache:/usr/local/bundle/gems_sidekiq
@@ -103,16 +107,17 @@ services:
- dawarich
stdin_open: true
tty: true
- entrypoint: dev-entrypoint.sh
+ entrypoint: entrypoint.sh
command: ['sidekiq']
restart: on-failure
environment:
RAILS_ENV: production
REDIS_URL: redis://dawarich_redis:6379/0
DATABASE_HOST: dawarich_db
+ DATABASE_PORT: 5432
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
- DATABASE_NAME: dawarich_development
+ DATABASE_NAME: dawarich_production
APPLICATION_HOST: localhost
APPLICATION_HOSTS: localhost
BACKGROUND_PROCESSING_CONCURRENCY: 10
diff --git a/docker-compose.yml b/docker/docker-compose.yml
similarity index 100%
rename from docker-compose.yml
rename to docker/docker-compose.yml
diff --git a/docker-compose_mounted_volumes.yml b/docker/docker-compose_mounted_volumes.yml
similarity index 100%
rename from docker-compose_mounted_volumes.yml
rename to docker/docker-compose_mounted_volumes.yml
diff --git a/docker/prod-docker-entrypoint.sh b/docker/prod-docker-entrypoint.sh
new file mode 100644
index 00000000..683ae2f0
--- /dev/null
+++ b/docker/prod-docker-entrypoint.sh
@@ -0,0 +1,93 @@
+#!/bin/sh
+
+unset BUNDLE_PATH
+unset BUNDLE_BIN
+
+set -e
+
+echo "Environment: $RAILS_ENV"
+
+# Parse DATABASE_URL if present, otherwise use individual variables
+if [ -n "$DATABASE_URL" ]; then
+ # Extract components from DATABASE_URL
+ DATABASE_HOST=$(echo $DATABASE_URL | awk -F[@/] '{print $4}')
+ DATABASE_PORT=$(echo $DATABASE_URL | awk -F[@/:] '{print $5}')
+ DATABASE_USERNAME=$(echo $DATABASE_URL | awk -F[:/@] '{print $4}')
+ DATABASE_PASSWORD=$(echo $DATABASE_URL | awk -F[:/@] '{print $5}')
+ DATABASE_NAME=$(echo $DATABASE_URL | awk -F[@/] '{print $5}')
+else
+ # Use existing environment variables
+ DATABASE_HOST=${DATABASE_HOST:-dawarich_db}
+ DATABASE_PORT=${DATABASE_PORT:-5432}
+ DATABASE_USERNAME=${DATABASE_USERNAME:-postgres}
+ DATABASE_PASSWORD=${DATABASE_PASSWORD:-password}
+ DATABASE_NAME=${DATABASE_NAME:-dawarich_production}
+fi
+
+# Function to test database connection
+test_db_connection() {
+ echo "Testing connection to PostgreSQL..."
+ echo "Host: $DATABASE_HOST"
+ echo "Port: $DATABASE_PORT"
+ echo "Username: $DATABASE_USERNAME"
+ echo "Database: postgres (default database)"
+
+ if PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d postgres -c "SELECT 1" > /dev/null 2>&1; then
+ echo "✅ Successfully connected to PostgreSQL!"
+ return 0
+ else
+ echo "❌ Failed to connect to PostgreSQL"
+ return 1
+ fi
+}
+
+# Try to connect to PostgreSQL, with a timeout
+max_attempts=30
+attempt=1
+
+while ! test_db_connection; do
+ if [ $attempt -ge $max_attempts ]; then
+ echo "Failed to connect to PostgreSQL after $max_attempts attempts. Exiting."
+ exit 1
+ fi
+
+ echo "Attempt $attempt of $max_attempts. Waiting 2 seconds before retry..."
+ attempt=$((attempt + 1))
+ sleep 2
+done
+
+# Remove pre-existing puma/passenger server.pid
+rm -f $APP_PATH/tmp/pids/server.pid
+
+# Install gems
+gem update --system 3.6.2
+gem install bundler --version '2.5.21'
+
+# Create the database if it doesn't exist
+if PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then
+ echo "Database $DATABASE_NAME already exists, skipping creation..."
+else
+ echo "Creating database $DATABASE_NAME..."
+ bundle exec rails db:create
+fi
+
+# Run database migrations
+echo "PostgreSQL is ready. Running database migrations..."
+bundle exec rails db:migrate
+
+# Run data migrations
+echo "Running DATA migrations..."
+bundle exec rake data:migrate
+
+# Run seeds
+echo "Running seeds..."
+bundle exec rake db:seed
+
+# Precompile assets
+if [ "$RAILS_ENV" = "production" ]; then
+ echo "Precompiling assets..."
+ bundle exec rake assets:precompile
+fi
+
+# run passed commands
+bundle exec ${@}
diff --git a/prod-docker-entrypoint.sh b/prod-docker-entrypoint.sh
deleted file mode 100644
index 52bac19a..00000000
--- a/prod-docker-entrypoint.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/sh
-
-unset BUNDLE_PATH
-unset BUNDLE_BIN
-
-set -e
-
-echo "Environment: $RAILS_ENV"
-
-# set env var defaults
-DATABASE_HOST=${DATABASE_HOST:-"dawarich_db"}
-DATABASE_PORT=${DATABASE_PORT:-5432}
-DATABASE_USERNAME=${DATABASE_USERNAME:-"postgres"}
-DATABASE_PASSWORD=${DATABASE_PASSWORD:-"password"}
-DATABASE_NAME=${DATABASE_NAME:-"dawarich_production"}
-
-# Remove pre-existing puma/passenger server.pid
-rm -f $APP_PATH/tmp/pids/server.pid
-
-# Wait for the database to be ready
-until nc -zv $DATABASE_HOST ${DATABASE_PORT:-5432}; do
- echo "Waiting for PostgreSQL to be ready..."
- sleep 1
-done
-
-# Install gems
-gem update --system 3.5.23
-gem install bundler --version '2.5.21'
-
-# Create the database
-if [ "$(psql "postgres://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT" -XtAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'")" = '1' ]; then
- echo "Database $DATABASE_NAME already exists, skipping creation..."
-else
- echo "Creating database $DATABASE_NAME..."
- bundle exec rails db:create
-fi
-
-# Run database migrations
-echo "PostgreSQL is ready. Running database migrations..."
-bundle exec rails db:prepare
-
-# Run data migrations
-echo "Running DATA migrations..."
-bundle exec rake data:migrate
-
-# Run seeds
-echo "Running seeds..."
-bundle exec rake db:seed
-
-# run passed commands
-bundle exec ${@}
From abff239d977eb6ce378ba8c25842f6455f375f80 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Wed, 8 Jan 2025 15:11:31 +0100
Subject: [PATCH 19/84] Run rails server instead of foreman
---
config/database.yml | 2 +-
docker/docker-compose.production.yml | 12 ++++++------
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/config/database.yml b/config/database.yml
index 1977b027..0dc98e97 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -20,4 +20,4 @@ test:
production:
<<: *default
database: <%= ENV['DATABASE_NAME'] || 'dawarich_production' %>
- url: <%= ENV['DATABASE_URL'] %>
+ # url: <%= ENV['DATABASE_URL'] %>
diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml
index 2399e731..fea4fa75 100644
--- a/docker/docker-compose.production.yml
+++ b/docker/docker-compose.production.yml
@@ -50,7 +50,7 @@ services:
stdin_open: true
tty: true
entrypoint: entrypoint.sh
- command: ['bin/dev']
+ command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
restart: on-failure
environment:
RAILS_ENV: production
@@ -61,8 +61,7 @@ services:
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_production
MIN_MINUTES_SPENT_IN_CITY: 60
- APPLICATION_HOST: localhost
- APPLICATION_HOSTS: localhost
+ APPLICATION_HOSTS: localhost,::1,127.0.0.1
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
@@ -108,7 +107,7 @@ services:
stdin_open: true
tty: true
entrypoint: entrypoint.sh
- command: ['sidekiq']
+ command: ['bundle', 'exec', 'sidekiq']
restart: on-failure
environment:
RAILS_ENV: production
@@ -118,8 +117,7 @@ services:
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_production
- APPLICATION_HOST: localhost
- APPLICATION_HOSTS: localhost
+ APPLICATION_HOSTS: localhost,::1,127.0.0.1
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
@@ -128,6 +126,8 @@ services:
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: dawarich_app
PROMETHEUS_EXPORTER_PORT: 9394
+ SECRET_KEY_BASE: 1234567890
+ RAILS_LOG_TO_STDOUT: "true"
logging:
driver: "json-file"
options:
From 69af9710f558242ff164205f5a595d4525fcc963 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 13:04:22 +0100
Subject: [PATCH 20/84] Clean up dockerfiles
---
app/controllers/api/v1/health_controller.rb | 1 -
docker/Dockerfile | 1 -
docker/Dockerfile.dev | 34 ++++++++---------
docker/prod-docker-entrypoint.sh | 42 +++------------------
4 files changed, 22 insertions(+), 56 deletions(-)
diff --git a/app/controllers/api/v1/health_controller.rb b/app/controllers/api/v1/health_controller.rb
index 53563cb0..62e594e9 100644
--- a/app/controllers/api/v1/health_controller.rb
+++ b/app/controllers/api/v1/health_controller.rb
@@ -8,4 +8,3 @@ class Api::V1::HealthController < ApiController
render json: { status: 'ok' }
end
end
-
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 07334a1f..73644a6a 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -21,7 +21,6 @@ RUN apk -U add --no-cache \
tzdata \
less \
yaml-dev \
- # gcompat for nokogiri on mac m1
gcompat \
&& rm -rf /var/cache/apk/* \
&& mkdir -p $APP_PATH
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index 8d668cf3..ad2336aa 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -1,7 +1,7 @@
FROM ruby:3.3.4-alpine
ENV APP_PATH=/var/app
-ENV BUNDLE_VERSION=2.5.9
+ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV TMP_PATH=/tmp/
ENV RAILS_LOG_TO_STDOUT=true
@@ -9,24 +9,24 @@ ENV RAILS_PORT=3000
# install dependencies for application
RUN apk -U add --no-cache \
- build-base \
- git \
- postgresql-dev \
- postgresql-client \
- libxml2-dev \
- libxslt-dev \
- nodejs \
- yarn \
- imagemagick \
- tzdata \
- less \
- # gcompat for nokogiri on mac m1
- gcompat \
- && rm -rf /var/cache/apk/* \
- && mkdir -p $APP_PATH
+ build-base \
+ git \
+ postgresql-dev \
+ postgresql-client \
+ libxml2-dev \
+ libxslt-dev \
+ nodejs \
+ yarn \
+ imagemagick \
+ tzdata \
+ less \
+ # gcompat for nokogiri on mac m1
+ gcompat \
+ && rm -rf /var/cache/apk/* \
+ && mkdir -p $APP_PATH
RUN gem install bundler --version "$BUNDLE_VERSION" \
- && rm -rf $GEM_HOME/cache/*
+ && rm -rf $GEM_HOME/cache/*
# copy entrypoint scripts and grant execution permissions
COPY ./dev-docker-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
diff --git a/docker/prod-docker-entrypoint.sh b/docker/prod-docker-entrypoint.sh
index 683ae2f0..50ff835c 100644
--- a/docker/prod-docker-entrypoint.sh
+++ b/docker/prod-docker-entrypoint.sh
@@ -17,45 +17,13 @@ if [ -n "$DATABASE_URL" ]; then
DATABASE_NAME=$(echo $DATABASE_URL | awk -F[@/] '{print $5}')
else
# Use existing environment variables
- DATABASE_HOST=${DATABASE_HOST:-dawarich_db}
- DATABASE_PORT=${DATABASE_PORT:-5432}
- DATABASE_USERNAME=${DATABASE_USERNAME:-postgres}
- DATABASE_PASSWORD=${DATABASE_PASSWORD:-password}
- DATABASE_NAME=${DATABASE_NAME:-dawarich_production}
+ DATABASE_HOST=${DATABASE_HOST}
+ DATABASE_PORT=${DATABASE_PORT}
+ DATABASE_USERNAME=${DATABASE_USERNAME}
+ DATABASE_PASSWORD=${DATABASE_PASSWORD}
+ DATABASE_NAME=${DATABASE_NAME}
fi
-# Function to test database connection
-test_db_connection() {
- echo "Testing connection to PostgreSQL..."
- echo "Host: $DATABASE_HOST"
- echo "Port: $DATABASE_PORT"
- echo "Username: $DATABASE_USERNAME"
- echo "Database: postgres (default database)"
-
- if PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -d postgres -c "SELECT 1" > /dev/null 2>&1; then
- echo "✅ Successfully connected to PostgreSQL!"
- return 0
- else
- echo "❌ Failed to connect to PostgreSQL"
- return 1
- fi
-}
-
-# Try to connect to PostgreSQL, with a timeout
-max_attempts=30
-attempt=1
-
-while ! test_db_connection; do
- if [ $attempt -ge $max_attempts ]; then
- echo "Failed to connect to PostgreSQL after $max_attempts attempts. Exiting."
- exit 1
- fi
-
- echo "Attempt $attempt of $max_attempts. Waiting 2 seconds before retry..."
- attempt=$((attempt + 1))
- sleep 2
-done
-
# Remove pre-existing puma/passenger server.pid
rm -f $APP_PATH/tmp/pids/server.pid
From 4d25dbca2107c8455b4bbad13fb547f433e516f2 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 13:38:13 +0100
Subject: [PATCH 21/84] Move some files around
---
.devcontainer/Dockerfile | 4 +-
.devcontainer/docker-compose.yml | 4 +-
app/jobs/visit_suggesting_job.rb | 6 ++-
...70930_remove_points_without_coordinates.rb | 2 +-
docker/Dockerfile | 2 +-
docker/Dockerfile.dev | 41 ---------------
docker/dev-docker-entrypoint.sh | 51 -------------------
docker/docker-compose.production.yml | 29 ++++++-----
docker/docker-compose.yml | 10 ++--
...ker-entrypoint.sh => docker-entrypoint.sh} | 2 +-
10 files changed, 32 insertions(+), 119 deletions(-)
delete mode 100644 docker/Dockerfile.dev
delete mode 100644 docker/dev-docker-entrypoint.sh
rename docker/{prod-docker-entrypoint.sh => docker-entrypoint.sh} (97%)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 39a87c65..a2077946 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -2,7 +2,7 @@
FROM ruby:3.3.4-alpine
ENV APP_PATH=/var/app
-ENV BUNDLE_VERSION=2.5.9
+ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV TMP_PATH=/tmp/
ENV RAILS_LOG_TO_STDOUT=true
@@ -27,7 +27,7 @@ RUN apk -U add --no-cache \
&& rm -rf /var/cache/apk/* \
&& mkdir -p $APP_PATH
-RUN gem update --system 3.5.7 && gem install bundler --version "$BUNDLE_VERSION" \
+RUN gem update --system 3.6.2 && gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
# FIXME It would be a good idea to use a other user than root, but this lead to permission error on export and maybe more yet.
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index e0bc7867..56fb8d5f 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -35,7 +35,7 @@ services:
PROMETHEUS_EXPORTER_PORT: 9394
ENABLE_TELEMETRY: false # More on telemetry: https://dawarich.app/docs/tutorials/telemetry
dawarich_redis:
- image: redis:7.0-alpine
+ image: redis:7.4-alpine
container_name: dawarich_redis
command: redis-server
networks:
@@ -50,7 +50,7 @@ services:
start_period: 30s
timeout: 10s
dawarich_db:
- image: postgres:14.2-alpine
+ image: postgres:17-alpine
container_name: dawarich_db
volumes:
- dawarich_db_data:/var/lib/postgresql/data
diff --git a/app/jobs/visit_suggesting_job.rb b/app/jobs/visit_suggesting_job.rb
index 06883b64..b1a3e13d 100644
--- a/app/jobs/visit_suggesting_job.rb
+++ b/app/jobs/visit_suggesting_job.rb
@@ -7,6 +7,10 @@ class VisitSuggestingJob < ApplicationJob
def perform(user_ids: [], start_at: 1.day.ago, end_at: Time.current)
users = user_ids.any? ? User.where(id: user_ids) : User.all
- users.find_each { Visits::Suggest.new(_1, start_at:, end_at:).call }
+ users.find_each do |user|
+ next if user.tracked_points.empty?
+
+ Visits::Suggest.new(user, start_at:, end_at:).call
+ end
end
end
diff --git a/db/data/20240610170930_remove_points_without_coordinates.rb b/db/data/20240610170930_remove_points_without_coordinates.rb
index b8647672..85ef7684 100644
--- a/db/data/20240610170930_remove_points_without_coordinates.rb
+++ b/db/data/20240610170930_remove_points_without_coordinates.rb
@@ -12,7 +12,7 @@ class RemovePointsWithoutCoordinates < ActiveRecord::Migration[7.1]
Rails.logger.info 'Points without coordinates removed.'
- BulkStatsCalculatingJob.perform_later(User.pluck(:id))
+ BulkStatsCalculatingJob.perform_later
end
def down
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 73644a6a..330334bf 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -40,7 +40,7 @@ RUN bundle config set --local path 'vendor/bundle' \
COPY ../. ./
# Copy entrypoint scripts and grant execution permissions
-COPY ./docker/prod-docker-entrypoint.sh /usr/local/bin/entrypoint.sh
+COPY ./docker/docker-entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE $RAILS_PORT
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
deleted file mode 100644
index ad2336aa..00000000
--- a/docker/Dockerfile.dev
+++ /dev/null
@@ -1,41 +0,0 @@
-FROM ruby:3.3.4-alpine
-
-ENV APP_PATH=/var/app
-ENV BUNDLE_VERSION=2.5.21
-ENV BUNDLE_PATH=/usr/local/bundle/gems
-ENV TMP_PATH=/tmp/
-ENV RAILS_LOG_TO_STDOUT=true
-ENV RAILS_PORT=3000
-
-# install dependencies for application
-RUN apk -U add --no-cache \
- build-base \
- git \
- postgresql-dev \
- postgresql-client \
- libxml2-dev \
- libxslt-dev \
- nodejs \
- yarn \
- imagemagick \
- tzdata \
- less \
- # gcompat for nokogiri on mac m1
- gcompat \
- && rm -rf /var/cache/apk/* \
- && mkdir -p $APP_PATH
-
-RUN gem install bundler --version "$BUNDLE_VERSION" \
- && rm -rf $GEM_HOME/cache/*
-
-# copy entrypoint scripts and grant execution permissions
-COPY ./dev-docker-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
-COPY ./test-docker-entrypoint.sh /usr/local/bin/test-entrypoint.sh
-RUN chmod +x /usr/local/bin/dev-entrypoint.sh && chmod +x /usr/local/bin/test-entrypoint.sh
-
-# navigate to app directory
-WORKDIR $APP_PATH
-
-EXPOSE $RAILS_PORT
-
-ENTRYPOINT [ "bundle", "exec" ]
diff --git a/docker/dev-docker-entrypoint.sh b/docker/dev-docker-entrypoint.sh
deleted file mode 100644
index 90a8ddfd..00000000
--- a/docker/dev-docker-entrypoint.sh
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/bin/sh
-
-unset BUNDLE_PATH
-unset BUNDLE_BIN
-
-set -e
-
-echo "Environment: $RAILS_ENV"
-
-# set env var defaults
-DATABASE_HOST=${DATABASE_HOST:-"dawarich_db"}
-DATABASE_PORT=${DATABASE_PORT:-5432}
-DATABASE_USERNAME=${DATABASE_USERNAME:-"postgres"}
-DATABASE_PASSWORD=${DATABASE_PASSWORD:-"password"}
-DATABASE_NAME=${DATABASE_NAME:-"dawarich_development"}
-
-# Remove pre-existing puma/passenger server.pid
-rm -f $APP_PATH/tmp/pids/server.pid
-
-# Wait for the database to be ready
-until nc -zv $DATABASE_HOST ${DATABASE_PORT:-5432}; do
- echo "Waiting for PostgreSQL to be ready..."
- sleep 1
-done
-
-# Install gems
-gem update --system 3.6.2
-gem install bundler --version '2.5.21'
-
-# Create the database
-if [ "$(psql "postgres://$DATABASE_USERNAME:$DATABASE_PASSWORD@$DATABASE_HOST:$DATABASE_PORT" -XtAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'")" = '1' ]; then
- echo "Database $DATABASE_NAME already exists, skipping creation..."
-else
- echo "Creating database $DATABASE_NAME..."
- bundle exec rails db:create
-fi
-
-# Run database migrations
-echo "PostgreSQL is ready. Running database migrations..."
-bundle exec rails db:migrate
-
-# Run data migrations
-echo "Running DATA migrations..."
-bundle exec rake data:migrate
-
-# Run seeds
-echo "Running seeds..."
-bundle exec rake db:seed
-
-# run passed commands
-bundle exec ${@}
diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml
index fea4fa75..ce5ba6db 100644
--- a/docker/docker-compose.production.yml
+++ b/docker/docker-compose.production.yml
@@ -8,7 +8,7 @@ services:
networks:
- dawarich
volumes:
- - shared_data:/var/shared/redis
+ - dawarich_redis_data:/var/shared/redis
restart: always
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
@@ -18,10 +18,10 @@ services:
timeout: 10s
dawarich_db:
image: postgres:17-alpine
+ shm_size: 1G
container_name: dawarich_db
volumes:
- - db_data:/var/lib/postgresql/data
- - shared_data:/var/shared
+ - dawarich_db_data:/var/lib/postgresql/data
networks:
- dawarich
environment:
@@ -39,9 +39,9 @@ services:
image: dawarich:prod
container_name: dawarich_app
volumes:
- - gem_cache:/usr/local/bundle/gems_app
- - public:/var/app/public
- - watched:/var/app/tmp/imports/watched
+ - dawarich_gem_cache_app:/usr/local/bundle/gems
+ - dawarich_public:/var/app/public
+ - dawarich_watched:/var/app/tmp/imports/watched
networks:
- dawarich
ports:
@@ -99,9 +99,9 @@ services:
image: dawarich:prod
container_name: dawarich_sidekiq
volumes:
- - gem_cache:/usr/local/bundle/gems_sidekiq
- - public:/var/app/public
- - watched:/var/app/tmp/imports/watched
+ - dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
+ - dawarich_public:/var/app/public
+ - dawarich_watched:/var/app/tmp/imports/watched
networks:
- dawarich
stdin_open: true
@@ -156,8 +156,9 @@ services:
memory: '2G' # Limit memory usage to 2GB
volumes:
- db_data:
- gem_cache:
- shared_data:
- public:
- watched:
+ dawarich_db_data:
+ dawarich_redis_data:
+ dawarich_gem_cache_app:
+ dawarich_gem_cache_sidekiq:
+ dawarich_public:
+ dawarich_watched:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 53586a39..ea7f5789 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -38,7 +38,7 @@ services:
timeout: 10s
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
dawarich_app:
- image: freikin/dawarich:latest
+ image: dawarich:prod
container_name: dawarich_app
volumes:
- dawarich_gem_cache_app:/usr/local/bundle/gems
@@ -51,8 +51,8 @@ services:
# - 9394:9394 # Prometheus exporter, uncomment if needed
stdin_open: true
tty: true
- entrypoint: dev-entrypoint.sh
- command: ['bin/dev']
+ entrypoint: entrypoint.sh
+ command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
restart: on-failure
environment:
RAILS_ENV: development
@@ -94,7 +94,7 @@ services:
cpus: '0.50' # Limit CPU usage to 50% of one core
memory: '2G' # Limit memory usage to 2GB
dawarich_sidekiq:
- image: freikin/dawarich:latest
+ image: dawarich:prod
container_name: dawarich_sidekiq
volumes:
- dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
@@ -104,7 +104,7 @@ services:
- dawarich
stdin_open: true
tty: true
- entrypoint: dev-entrypoint.sh
+ entrypoint: entrypoint.sh
command: ['sidekiq']
restart: on-failure
environment:
diff --git a/docker/prod-docker-entrypoint.sh b/docker/docker-entrypoint.sh
similarity index 97%
rename from docker/prod-docker-entrypoint.sh
rename to docker/docker-entrypoint.sh
index 50ff835c..1fe36928 100644
--- a/docker/prod-docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -5,7 +5,7 @@ unset BUNDLE_BIN
set -e
-echo "Environment: $RAILS_ENV"
+echo "⚠️ Environment: $RAILS_ENV ⚠️"
# Parse DATABASE_URL if present, otherwise use individual variables
if [ -n "$DATABASE_URL" ]; then
From 3312ea794f2f65774eafd4527704654336f249f7 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 14:44:16 +0100
Subject: [PATCH 22/84] Add separate entrypoints for web and sidekiq
---
docker/Dockerfile | 25 ++++++++++----
docker/docker-compose.production.yml | 4 +--
docker/docker-compose.yml | 4 +--
docker/sidekiq-entrypoint.sh | 34 +++++++++++++++++++
...docker-entrypoint.sh => web-entrypoint.sh} | 29 +++++++---------
5 files changed, 70 insertions(+), 26 deletions(-)
create mode 100644 docker/sidekiq-entrypoint.sh
rename docker/{docker-entrypoint.sh => web-entrypoint.sh} (63%)
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 330334bf..ce4d8455 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -3,7 +3,6 @@ FROM ruby:3.3.4-alpine
ENV APP_PATH=/var/app
ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
-ENV TMP_PATH=/tmp/
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_PORT=3000
@@ -22,10 +21,11 @@ RUN apk -U add --no-cache \
less \
yaml-dev \
gcompat \
- && rm -rf /var/cache/apk/* \
&& mkdir -p $APP_PATH
-RUN gem install bundler --version "$BUNDLE_VERSION" \
+# Update gem system and install bundler
+RUN gem update --system 3.6.2 \
+ && gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
# Navigate to app directory
@@ -35,13 +35,26 @@ COPY ../Gemfile ../Gemfile.lock ../vendor ../.ruby-version ./
# Install missing gems
RUN bundle config set --local path 'vendor/bundle' \
- && bundle install --jobs 20 --retry 5
+ && if [ "$RAILS_ENV" = "production" ]; then \
+ bundle install --jobs 4 --retry 3 --without development test; \
+ else \
+ bundle install --jobs 4 --retry 3; \
+ fi
COPY ../. ./
+# Precompile assets for production
+RUN if [ "$RAILS_ENV" = "production" ]; then \
+ bundle exec rake assets:precompile \
+ && rm -rf node_modules tmp/cache; \
+ fi
+
# Copy entrypoint scripts and grant execution permissions
-COPY ./docker/docker-entrypoint.sh /usr/local/bin/entrypoint.sh
-RUN chmod +x /usr/local/bin/entrypoint.sh
+COPY ./docker/web-entrypoint.sh /usr/local/bin/web-entrypoint.sh
+RUN chmod +x /usr/local/bin/web-entrypoint.sh
+
+COPY ./docker/sidekiq-entrypoint.sh /usr/local/bin/sidekiq-entrypoint.sh
+RUN chmod +x /usr/local/bin/sidekiq-entrypoint.sh
EXPOSE $RAILS_PORT
diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml
index ce5ba6db..3603eefb 100644
--- a/docker/docker-compose.production.yml
+++ b/docker/docker-compose.production.yml
@@ -49,7 +49,7 @@ services:
# - 9394:9394 # Prometheus exporter, uncomment if needed
stdin_open: true
tty: true
- entrypoint: entrypoint.sh
+ entrypoint: web-entrypoint.sh
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
restart: on-failure
environment:
@@ -106,7 +106,7 @@ services:
- dawarich
stdin_open: true
tty: true
- entrypoint: entrypoint.sh
+ entrypoint: sidekiq-entrypoint.sh
command: ['bundle', 'exec', 'sidekiq']
restart: on-failure
environment:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index ea7f5789..bee3df25 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -51,7 +51,7 @@ services:
# - 9394:9394 # Prometheus exporter, uncomment if needed
stdin_open: true
tty: true
- entrypoint: entrypoint.sh
+ entrypoint: web-entrypoint.sh
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
restart: on-failure
environment:
@@ -104,7 +104,7 @@ services:
- dawarich
stdin_open: true
tty: true
- entrypoint: entrypoint.sh
+ entrypoint: sidekiq-entrypoint.sh
command: ['sidekiq']
restart: on-failure
environment:
diff --git a/docker/sidekiq-entrypoint.sh b/docker/sidekiq-entrypoint.sh
new file mode 100644
index 00000000..1083891b
--- /dev/null
+++ b/docker/sidekiq-entrypoint.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+unset BUNDLE_PATH
+unset BUNDLE_BIN
+
+set -e
+
+echo "⚠️ Starting Sidekiq in $RAILS_ENV environment ⚠️"
+
+# Parse DATABASE_URL if present, otherwise use individual variables
+if [ -n "$DATABASE_URL" ]; then
+ # Extract components from DATABASE_URL
+ DATABASE_HOST=$(echo $DATABASE_URL | awk -F[@/] '{print $4}')
+ DATABASE_PORT=$(echo $DATABASE_URL | awk -F[@/:] '{print $5}')
+ DATABASE_USERNAME=$(echo $DATABASE_URL | awk -F[:/@] '{print $4}')
+ DATABASE_PASSWORD=$(echo $DATABASE_URL | awk -F[:/@] '{print $5}')
+else
+ # Use existing environment variables
+ DATABASE_HOST=${DATABASE_HOST}
+ DATABASE_PORT=${DATABASE_PORT}
+ DATABASE_USERNAME=${DATABASE_USERNAME}
+ DATABASE_PASSWORD=${DATABASE_PASSWORD}
+fi
+
+# Wait for the database to become available
+echo "⏳ Waiting for database to be ready..."
+until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do
+ >&2 echo "Postgres is unavailable - retrying..."
+ sleep 2
+done
+echo "✅ PostgreSQL is ready!"
+
+# run sidekiq
+bundle exec sidekiq
diff --git a/docker/docker-entrypoint.sh b/docker/web-entrypoint.sh
similarity index 63%
rename from docker/docker-entrypoint.sh
rename to docker/web-entrypoint.sh
index 1fe36928..cfd52116 100644
--- a/docker/docker-entrypoint.sh
+++ b/docker/web-entrypoint.sh
@@ -5,7 +5,7 @@ unset BUNDLE_BIN
set -e
-echo "⚠️ Environment: $RAILS_ENV ⚠️"
+echo "⚠️ Starting Rails environment: $RAILS_ENV ⚠️"
# Parse DATABASE_URL if present, otherwise use individual variables
if [ -n "$DATABASE_URL" ]; then
@@ -27,14 +27,16 @@ fi
# Remove pre-existing puma/passenger server.pid
rm -f $APP_PATH/tmp/pids/server.pid
-# Install gems
-gem update --system 3.6.2
-gem install bundler --version '2.5.21'
+# Wait for the database to become available
+echo "⏳ Waiting for database to be ready..."
+until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c '\q'; do
+ >&2 echo "Postgres is unavailable - retrying..."
+ sleep 2
+done
+echo "✅ PostgreSQL is ready!"
-# Create the database if it doesn't exist
-if PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then
- echo "Database $DATABASE_NAME already exists, skipping creation..."
-else
+# Create database if it doesn't exist
+if ! PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -p "$DATABASE_PORT" -U "$DATABASE_USERNAME" -c "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'" | grep -q 1; then
echo "Creating database $DATABASE_NAME..."
bundle exec rails db:create
fi
@@ -47,14 +49,9 @@ bundle exec rails db:migrate
echo "Running DATA migrations..."
bundle exec rake data:migrate
-# Run seeds
-echo "Running seeds..."
-bundle exec rake db:seed
-
-# Precompile assets
-if [ "$RAILS_ENV" = "production" ]; then
- echo "Precompiling assets..."
- bundle exec rake assets:precompile
+if [ "$RAILS_ENV" != "production" ]; then
+ echo "Running seeds..."
+ bundle exec rails db:seed
fi
# run passed commands
From 1b6273ba1c11896bde27e5f92a83d0b8db2fac5c Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 14:47:21 +0100
Subject: [PATCH 23/84] Fix visit suggesting job spec
---
spec/jobs/visit_suggesting_job_spec.rb | 22 +++++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/spec/jobs/visit_suggesting_job_spec.rb b/spec/jobs/visit_suggesting_job_spec.rb
index 70af4e74..f2ce47d9 100644
--- a/spec/jobs/visit_suggesting_job_spec.rb
+++ b/spec/jobs/visit_suggesting_job_spec.rb
@@ -3,9 +3,9 @@
require 'rails_helper'
RSpec.describe VisitSuggestingJob, type: :job do
- describe '#perform' do
- let!(:users) { [create(:user)] }
+ let!(:users) { [create(:user)] }
+ describe '#perform' do
subject { described_class.perform_now }
before do
@@ -13,10 +13,22 @@ RSpec.describe VisitSuggestingJob, type: :job do
allow_any_instance_of(Visits::Suggest).to receive(:call)
end
- it 'suggests visits' do
- subject
+ context 'when user has no tracked points' do
+ it 'does not suggest visits' do
+ subject
- expect(Visits::Suggest).to have_received(:new)
+ expect(Visits::Suggest).not_to have_received(:new)
+ end
+ end
+
+ context 'when user has tracked points' do
+ let!(:tracked_point) { create(:point, user: users.first) }
+
+ it 'suggests visits' do
+ subject
+
+ expect(Visits::Suggest).to have_received(:new)
+ end
end
end
end
From c13ebe8d3c2e61ae2ded3fbb1d7382efc22a8c01 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 15:04:05 +0100
Subject: [PATCH 24/84] Split docker files
---
.app_version | 2 +-
.github/workflows/build_and_push.yml | 2 +-
CHANGELOG.md | 22 ++++++++++++
config/database.yml | 1 -
docker/Dockerfile.dev | 50 ++++++++++++++++++++++++++
docker/{Dockerfile => Dockerfile.prod} | 16 +++------
docker/docker-compose.production.yml | 4 ---
docker/docker-compose.yml | 4 +--
8 files changed, 81 insertions(+), 20 deletions(-)
create mode 100644 docker/Dockerfile.dev
rename docker/{Dockerfile => Dockerfile.prod} (75%)
diff --git a/.app_version b/.app_version
index 78cfa5eb..21574090 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.21.6
+0.22.0
diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml
index 05173d53..00a78a71 100644
--- a/.github/workflows/build_and_push.yml
+++ b/.github/workflows/build_and_push.yml
@@ -40,7 +40,7 @@ jobs:
uses: docker/build-push-action@v2
with:
context: .
- file: ./docker/Dockerfile
+ file: ./docker/Dockerfile.dev
push: true
tags: freikin/dawarich:latest,freikin/dawarich:${{ github.event.inputs.branch || github.ref_name }}
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b2e1169..d02b51c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,28 @@ 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.22.0 - 2025-01-09
+
+⚠️ This release introduces a breaking change. ⚠️
+
+Please read this release notes carefully before upgrading.
+
+### Changed
+
+- All docker-related files were moved to the `docker` directory.
+- Default memory limit for `dawarich_app` and `dawarich_sidekiq` services was increased to 4GB.
+- `dawarich_app` and `dawarich_sidekiq` services now use separate entrypoint scripts.
+- Gems (dependency libraries) are now being shipped as part of the Dawarich Docker image.
+
+### Fixed
+
+- Visit suggesting job does nothing if user has no tracked points.
+- `BulkStatsCalculationJob` now being called without arguments in the data migration.
+
+### Added
+
+- A proper production Dockerfile, docker-compose and env files.
+
# 0.21.6 - 2025-01-07
### Changed
diff --git a/config/database.yml b/config/database.yml
index 0dc98e97..fca7a51c 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -20,4 +20,3 @@ test:
production:
<<: *default
database: <%= ENV['DATABASE_NAME'] || 'dawarich_production' %>
- # url: <%= ENV['DATABASE_URL'] %>
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
new file mode 100644
index 00000000..381ece15
--- /dev/null
+++ b/docker/Dockerfile.dev
@@ -0,0 +1,50 @@
+FROM ruby:3.3.4-alpine
+
+ENV APP_PATH=/var/app
+ENV BUNDLE_VERSION=2.5.21
+ENV BUNDLE_PATH=/usr/local/bundle/gems
+ENV RAILS_LOG_TO_STDOUT=true
+ENV RAILS_PORT=3000
+
+# Install dependencies for application
+RUN apk -U add --no-cache \
+ build-base \
+ git \
+ postgresql-dev \
+ postgresql-client \
+ libxml2-dev \
+ libxslt-dev \
+ nodejs \
+ yarn \
+ imagemagick \
+ tzdata \
+ less \
+ yaml-dev \
+ gcompat \
+ && mkdir -p $APP_PATH
+
+# Update gem system and install bundler
+RUN gem update --system 3.6.2 \
+ && gem install bundler --version "$BUNDLE_VERSION" \
+ && rm -rf $GEM_HOME/cache/*
+
+WORKDIR $APP_PATH
+
+COPY ../Gemfile ../Gemfile.lock ../vendor ../.ruby-version ./
+
+# Install all gems including development and test
+RUN bundle config set --local path 'vendor/bundle' \
+ && bundle install --jobs 4 --retry 3
+
+COPY ../. ./
+
+# Copy entrypoint scripts and grant execution permissions
+COPY ./docker/web-entrypoint.sh /usr/local/bin/web-entrypoint.sh
+RUN chmod +x /usr/local/bin/web-entrypoint.sh
+
+COPY ./docker/sidekiq-entrypoint.sh /usr/local/bin/sidekiq-entrypoint.sh
+RUN chmod +x /usr/local/bin/sidekiq-entrypoint.sh
+
+EXPOSE $RAILS_PORT
+
+ENTRYPOINT [ "bundle", "exec" ]
diff --git a/docker/Dockerfile b/docker/Dockerfile.prod
similarity index 75%
rename from docker/Dockerfile
rename to docker/Dockerfile.prod
index ce4d8455..a8f6a449 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile.prod
@@ -5,6 +5,7 @@ ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_PORT=3000
+ENV RAILS_ENV=production
# Install dependencies for application
RUN apk -U add --no-cache \
@@ -28,26 +29,19 @@ RUN gem update --system 3.6.2 \
&& gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
-# Navigate to app directory
WORKDIR $APP_PATH
COPY ../Gemfile ../Gemfile.lock ../vendor ../.ruby-version ./
-# Install missing gems
+# Install production gems only
RUN bundle config set --local path 'vendor/bundle' \
- && if [ "$RAILS_ENV" = "production" ]; then \
- bundle install --jobs 4 --retry 3 --without development test; \
- else \
- bundle install --jobs 4 --retry 3; \
- fi
+ && bundle install --jobs 4 --retry 3 --without development test
COPY ../. ./
# Precompile assets for production
-RUN if [ "$RAILS_ENV" = "production" ]; then \
- bundle exec rake assets:precompile \
- && rm -rf node_modules tmp/cache; \
- fi
+RUN bundle exec rake assets:precompile \
+ && rm -rf node_modules tmp/cache
# Copy entrypoint scripts and grant execution permissions
COPY ./docker/web-entrypoint.sh /usr/local/bin/web-entrypoint.sh
diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml
index 3603eefb..df815317 100644
--- a/docker/docker-compose.production.yml
+++ b/docker/docker-compose.production.yml
@@ -65,8 +65,6 @@ services:
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
- PHOTON_API_HOST: photon.komoot.io
- PHOTON_API_USE_HTTPS: true
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
@@ -121,8 +119,6 @@ services:
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
- PHOTON_API_HOST: photon.komoot.io
- PHOTON_API_USE_HTTPS: true
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: dawarich_app
PROMETHEUS_EXPORTER_PORT: 9394
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index bee3df25..e44ab15c 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -92,7 +92,7 @@ services:
resources:
limits:
cpus: '0.50' # Limit CPU usage to 50% of one core
- memory: '2G' # Limit memory usage to 2GB
+ memory: '4G' # Limit memory usage to 4GB
dawarich_sidekiq:
image: dawarich:prod
container_name: dawarich_sidekiq
@@ -147,7 +147,7 @@ services:
resources:
limits:
cpus: '0.50' # Limit CPU usage to 50% of one core
- memory: '2G' # Limit memory usage to 2GB
+ memory: '4G' # Limit memory usage to 4GB
volumes:
dawarich_db_data:
From 1e83330d291fcb9574e1c07b985b11a63889d877 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 15:15:56 +0100
Subject: [PATCH 25/84] Update changelog
---
CHANGELOG.md | 4 ++++
docker/docker-compose.production.yml | 4 ++--
docker/docker-compose.yml | 4 ++--
3 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d02b51c3..bd521f86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
Please read this release notes carefully before upgrading.
+Docker-related files were moved to the `docker` directory and some of them were renamed. Before upgrading, study carefully changes in the `docker/docker-compose.yml` file and update your docker-compose file accordingly, so it uses the new files and commands. Copying `docker/docker-compose.yml` blindly may lead to errors.
+
+No volumes were removed or renamed, so with a proper docker-compose file, you should be able to upgrade without any issues. To make it easier comparing your existing docker-compose file with the new one, you may use https://www.diffchecker.com/.
+
### Changed
- All docker-related files were moved to the `docker` directory.
diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml
index df815317..1eb90902 100644
--- a/docker/docker-compose.production.yml
+++ b/docker/docker-compose.production.yml
@@ -36,7 +36,7 @@ services:
start_period: 30s
timeout: 10s
dawarich_app:
- image: dawarich:prod
+ image: freikin/dawarich:latest
container_name: dawarich_app
volumes:
- dawarich_gem_cache_app:/usr/local/bundle/gems
@@ -94,7 +94,7 @@ services:
cpus: '0.50' # Limit CPU usage to 50% of one core
memory: '2G' # Limit memory usage to 2GB
dawarich_sidekiq:
- image: dawarich:prod
+ image: freikin/dawarich:latest
container_name: dawarich_sidekiq
volumes:
- dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index e44ab15c..d2154c2c 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -38,7 +38,7 @@ services:
timeout: 10s
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
dawarich_app:
- image: dawarich:prod
+ image: freikin/dawarich:latest
container_name: dawarich_app
volumes:
- dawarich_gem_cache_app:/usr/local/bundle/gems
@@ -94,7 +94,7 @@ services:
cpus: '0.50' # Limit CPU usage to 50% of one core
memory: '4G' # Limit memory usage to 4GB
dawarich_sidekiq:
- image: dawarich:prod
+ image: freikin/dawarich:latest
container_name: dawarich_sidekiq
volumes:
- dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
From 7766fcbd6a2463b47f9dc8b0a5545f927cfc8dc8 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 15:19:31 +0100
Subject: [PATCH 26/84] Update changelog
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd521f86..09c51769 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@ Docker-related files were moved to the `docker` directory and some of them were
No volumes were removed or renamed, so with a proper docker-compose file, you should be able to upgrade without any issues. To make it easier comparing your existing docker-compose file with the new one, you may use https://www.diffchecker.com/.
+Although `docker-compose.production.yml` was added, it's not being used by default. It's just an example of how to configure Dawarich for production. The default `docker-compose.yml` file is still recommended for running the app.
+
### Changed
- All docker-related files were moved to the `docker` directory.
From 485f23f82d459ba34afa377f5c63d561bb35e075 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 20:51:19 +0100
Subject: [PATCH 27/84] Remove unused volumes from docker-compose.yml
---
.app_version | 2 +-
.devcontainer/Dockerfile | 2 +-
.devcontainer/docker-compose.yml | 3 --
CHANGELOG.md | 55 ++++++++++++++++++++-
app/controllers/api/v1/health_controller.rb | 7 ++-
docker/Dockerfile.dev | 9 ++--
docker/Dockerfile.prod | 8 ++-
docker/docker-compose.production.yml | 4 --
docker/docker-compose.yml | 4 --
docker/docker-compose_mounted_volumes.yml | 6 ---
docs/How_to_install_Dawarich_on_Synology.md | 2 +-
docs/synology/docker-compose.yml | 6 +--
spec/requests/api/v1/health_spec.rb | 12 +++++
13 files changed, 89 insertions(+), 31 deletions(-)
diff --git a/.app_version b/.app_version
index 21574090..a723ece7 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.22.0
+0.22.1
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index a2077946..a61f0adb 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-# Basis-Image für Ruby und Node.js
+# Base-Image for Ruby and Node.js
FROM ruby:3.3.4-alpine
ENV APP_PATH=/var/app
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 56fb8d5f..e2b7aeb3 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -8,7 +8,6 @@ services:
container_name: dawarich_dev
volumes:
- "${PWD}:/var/app:cached"
- - dawarich_gem_cache_app:/usr/local/bundle/gems_app
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:
@@ -69,8 +68,6 @@ services:
POSTGRES_PASSWORD: password
volumes:
dawarich_db_data:
- dawarich_gem_cache_app:
- dawarich_gem_cache_sidekiq:
dawarich_shared:
dawarich_public:
dawarich_watched:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09c51769..720175b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,39 @@ 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.22.1 - 2025-01-09
+
+### Removed
+
+- Gems caching volume from the `docker-compose.yml` file.
+
+```diff
+ dawarich_app:
+ image: freikin/dawarich:latest
+...
+ volumes:
+- - dawarich_gem_cache_app:/usr/local/bundle/gems
+...
+ dawarich_sidekiq:
+ image: freikin/dawarich:latest
+...
+ volumes:
+- - dawarich_gem_cache_app:/usr/local/bundle/gems
+...
+
+volumes:
+ dawarich_db_data:
+- dawarich_gem_cache_app:
+- dawarich_gem_cache_sidekiq:
+ dawarich_shared:
+ dawarich_public:
+ dawarich_watched:
+```
+
+### Changed
+
+- `GET /api/v1/health` endpoint now returns a `X-Dawarich-Response: Hey, Im alive and authenticated!` header if user is authenticated.
+
# 0.22.0 - 2025-01-09
⚠️ This release introduces a breaking change. ⚠️
@@ -13,7 +46,27 @@ Please read this release notes carefully before upgrading.
Docker-related files were moved to the `docker` directory and some of them were renamed. Before upgrading, study carefully changes in the `docker/docker-compose.yml` file and update your docker-compose file accordingly, so it uses the new files and commands. Copying `docker/docker-compose.yml` blindly may lead to errors.
-No volumes were removed or renamed, so with a proper docker-compose file, you should be able to upgrade without any issues. To make it easier comparing your existing docker-compose file with the new one, you may use https://www.diffchecker.com/.
+No volumes were removed or renamed, so with a proper docker-compose file, you should be able to upgrade without any issues.
+
+To update existing `docker-compose.yml` to new changes, refer to the following:
+
+```diff
+ dawarich_app:
+ image: freikin/dawarich:latest
+...
+- entrypoint: dev-entrypoint.sh
+- command: ['bin/dev']
++ entrypoint: web-entrypoint.sh
++ command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
+...
+ dawarich_sidekiq:
+ image: freikin/dawarich:latest
+...
+- entrypoint: dev-entrypoint.sh
+- command: ['bin/dev']
++ entrypoint: sidekiq-entrypoint.sh
++ command: ['bundle', 'exec', 'sidekiq']
+```
Although `docker-compose.production.yml` was added, it's not being used by default. It's just an example of how to configure Dawarich for production. The default `docker-compose.yml` file is still recommended for running the app.
diff --git a/app/controllers/api/v1/health_controller.rb b/app/controllers/api/v1/health_controller.rb
index 62e594e9..87df7d96 100644
--- a/app/controllers/api/v1/health_controller.rb
+++ b/app/controllers/api/v1/health_controller.rb
@@ -4,7 +4,12 @@ class Api::V1::HealthController < ApiController
skip_before_action :authenticate_api_key
def index
- response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!')
+ if current_api_user
+ response.set_header('X-Dawarich-Response', 'Hey, I\'m alive and authenticated!')
+ else
+ response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!')
+ end
+
render json: { status: 'ok' }
end
end
diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev
index 381ece15..37b04015 100644
--- a/docker/Dockerfile.dev
+++ b/docker/Dockerfile.dev
@@ -5,6 +5,7 @@ ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_PORT=3000
+ENV RAILS_ENV=development
# Install dependencies for application
RUN apk -U add --no-cache \
@@ -30,12 +31,14 @@ RUN gem update --system 3.6.2 \
WORKDIR $APP_PATH
-COPY ../Gemfile ../Gemfile.lock ../vendor ../.ruby-version ./
+COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
-# Install all gems including development and test
+# Install all gems into the image
RUN bundle config set --local path 'vendor/bundle' \
- && bundle install --jobs 4 --retry 3
+ && bundle install --jobs 4 --retry 3 \
+ && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem
+# Copy the rest of the application
COPY ../. ./
# Copy entrypoint scripts and grant execution permissions
diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod
index a8f6a449..ae36af49 100644
--- a/docker/Dockerfile.prod
+++ b/docker/Dockerfile.prod
@@ -6,6 +6,8 @@ ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_PORT=3000
ENV RAILS_ENV=production
+ENV SECRET_KEY_BASE=$SECRET_KEY_BASE
+
# Install dependencies for application
RUN apk -U add --no-cache \
@@ -31,11 +33,13 @@ RUN gem update --system 3.6.2 \
WORKDIR $APP_PATH
-COPY ../Gemfile ../Gemfile.lock ../vendor ../.ruby-version ./
+COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
# Install production gems only
RUN bundle config set --local path 'vendor/bundle' \
- && bundle install --jobs 4 --retry 3 --without development test
+ && bundle config set --local without 'development test' \
+ && bundle install --jobs 4 --retry 3 \
+ && rm -rf vendor/bundle/ruby/3.3.0/cache/*.gem
COPY ../. ./
diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml
index 1eb90902..90bfec24 100644
--- a/docker/docker-compose.production.yml
+++ b/docker/docker-compose.production.yml
@@ -39,7 +39,6 @@ services:
image: freikin/dawarich:latest
container_name: dawarich_app
volumes:
- - dawarich_gem_cache_app:/usr/local/bundle/gems
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:
@@ -97,7 +96,6 @@ services:
image: freikin/dawarich:latest
container_name: dawarich_sidekiq
volumes:
- - dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:
@@ -154,7 +152,5 @@ services:
volumes:
dawarich_db_data:
dawarich_redis_data:
- dawarich_gem_cache_app:
- dawarich_gem_cache_sidekiq:
dawarich_public:
dawarich_watched:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index d2154c2c..93c0296f 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -41,7 +41,6 @@ services:
image: freikin/dawarich:latest
container_name: dawarich_app
volumes:
- - dawarich_gem_cache_app:/usr/local/bundle/gems
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:
@@ -97,7 +96,6 @@ services:
image: freikin/dawarich:latest
container_name: dawarich_sidekiq
volumes:
- - dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
networks:
@@ -151,8 +149,6 @@ services:
volumes:
dawarich_db_data:
- dawarich_gem_cache_app:
- dawarich_gem_cache_sidekiq:
dawarich_shared:
dawarich_public:
dawarich_watched:
diff --git a/docker/docker-compose_mounted_volumes.yml b/docker/docker-compose_mounted_volumes.yml
index 6a712834..ef61f49a 100644
--- a/docker/docker-compose_mounted_volumes.yml
+++ b/docker/docker-compose_mounted_volumes.yml
@@ -3,10 +3,6 @@ networks:
volumes:
- dawarich_gem_cache_app:
- name: dawarich_gem_cache_app
- dawarich_gem_cache_sidekiq:
- name: dawarich_gem_cache_sidekiq
dawarich_public:
name: dawarich_public
dawarich_keydb:
@@ -49,7 +45,6 @@ services:
entrypoint: dev-entrypoint.sh
command: [ 'bin/dev' ]
volumes:
- - dawarich_gem_cache_app:/usr/local/bundle/gems
- dawarich_public:/var/app/dawarich_public
- watched:/var/app/tmp/imports/watched
healthcheck:
@@ -102,7 +97,6 @@ services:
entrypoint: dev-entrypoint.sh
command: [ 'sidekiq' ]
volumes:
- - dawarich_gem_cache_sidekiq:/usr/local/bundle/gems
- dawarich_public:/var/app/dawarich_public
- watched:/var/app/tmp/imports/watched
logging:
diff --git a/docs/How_to_install_Dawarich_on_Synology.md b/docs/How_to_install_Dawarich_on_Synology.md
index 228d5137..ff17f1a8 100644
--- a/docs/How_to_install_Dawarich_on_Synology.md
+++ b/docs/How_to_install_Dawarich_on_Synology.md
@@ -29,7 +29,7 @@ If you don't want to use dedicated share for projects installed by docker skip i
### Dawarich root folder
1. Open your [Docker root folder](#docker-root-share) in **File station**.
2. Create new folder **dawarich** and open it.
-3. Create folders **redis**, **db_data**, **db_shared**, **gem_cache** and **public** in **dawarich** folder.
+3. Create folders **redis**, **db_data**, **db_shared** and **public** in **dawarich** folder.
4. Copy [docker compose](synology/docker-compose.yml) and [.env](synology/.env) files form **synology** repo folder into **dawarich** folder on your synology.
# Installation
diff --git a/docs/synology/docker-compose.yml b/docs/synology/docker-compose.yml
index 6a9f14ed..5544db41 100644
--- a/docs/synology/docker-compose.yml
+++ b/docs/synology/docker-compose.yml
@@ -8,7 +8,7 @@ services:
restart: unless-stopped
volumes:
- ./redis:/var/shared/redis
-
+
dawarich_db:
image: postgres:14.2-alpine
container_name: dawarich_db
@@ -34,11 +34,10 @@ services:
env_file:
- .env
volumes:
- - ./gem_cache:/usr/local/bundle/gems
- ./public:/var/app/public
ports:
- 32568:3000
-
+
dawarich_sidekiq:
image: freikin/dawarich:latest
container_name: dawarich_sidekiq
@@ -52,5 +51,4 @@ services:
env_file:
- .env
volumes:
- - ./gem_cache:/usr/local/bundle/gems
- ./public:/var/app/public
diff --git a/spec/requests/api/v1/health_spec.rb b/spec/requests/api/v1/health_spec.rb
index 264dbdcb..4861b399 100644
--- a/spec/requests/api/v1/health_spec.rb
+++ b/spec/requests/api/v1/health_spec.rb
@@ -9,6 +9,18 @@ RSpec.describe 'Api::V1::Healths', type: :request do
get '/api/v1/health'
expect(response).to have_http_status(:success)
+ expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive!')
+ end
+ end
+
+ context 'when user is authenticated' do
+ let(:user) { create(:user) }
+
+ it 'returns http success' do
+ get '/api/v1/health', headers: { 'Authorization' => "Bearer #{user.api_key}" }
+
+ expect(response).to have_http_status(:success)
+ expect(response.headers['X-Dawarich-Response']).to eq('Hey, I\'m alive and authenticated!')
end
end
end
From 4d3daf24fb54c4c7c8a8f1f9a8da3a5e61b8d522 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Thu, 9 Jan 2025 20:52:30 +0100
Subject: [PATCH 28/84] Update changelog
---
CHANGELOG.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 720175b8..f7f7be29 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Gems caching volume from the `docker-compose.yml` file.
+To update existing `docker-compose.yml` to new changes, refer to the following:
+
```diff
dawarich_app:
image: freikin/dawarich:latest
From 43e4e8d81a80fd2dcc96994b806452b7169d865d Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Fri, 10 Jan 2025 23:03:07 +0100
Subject: [PATCH 29/84] Color polylines based on speed
---
app/javascript/controllers/maps_controller.js | 18 +-
app/javascript/maps/polylines.js | 223 +++++++++++++-----
2 files changed, 174 insertions(+), 67 deletions(-)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 40893763..028240b2 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -758,10 +758,9 @@ export default class extends Controller {
this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
- // Preserve existing layer instances if they exist
+ // Preserve existing layers except polylines which need to be recreated
const preserveLayers = {
Points: this.markersLayer,
- Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer,
@@ -774,12 +773,15 @@ export default class extends Controller {
}
});
- // Recreate layers only if they don't exist
- this.markersLayer = preserveLayers.Points || L.layerGroup(createMarkersArray(this.markers, newSettings));
- this.polylinesLayer = preserveLayers.Polylines || createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity, this.userSettings, this.distanceUnit);
- this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 });
- this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup();
- this.areasLayer = preserveLayers.Areas || L.layerGroup();
+ // Recreate polylines layer with new settings
+ this.polylinesLayer = createPolylinesLayer(
+ this.markers,
+ this.map,
+ this.timezone,
+ this.routeOpacity,
+ newSettings,
+ this.distanceUnit
+ );
// Redraw areas
fetchAndDrawAreas(this.areasLayer, this.apiKey);
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index 2c09022d..bd0a1595 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -4,11 +4,50 @@ import { getUrlParameter } from "../maps/helpers";
import { minutesToDaysHoursMinutes } from "../maps/helpers";
import { haversineDistance } from "../maps/helpers";
-export function addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit) {
- const originalStyle = { color: "blue", opacity: userSettings.routeOpacity, weight: 3 };
- const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
+function getSpeedColor(speedKmh) {
+ console.log('Speed to color:', speedKmh + ' km/h');
- polyline.setStyle(originalStyle);
+ if (speedKmh > 100) {
+ console.log('Red - Very fast');
+ return '#FF0000';
+ }
+ if (speedKmh > 70) {
+ console.log('Orange - Fast');
+ return '#FFA500';
+ }
+ if (speedKmh > 40) {
+ console.log('Yellow - Moderate');
+ return '#FFFF00';
+ }
+ if (speedKmh > 20) {
+ console.log('Light green - Normal');
+ return '#90EE90';
+ }
+ console.log('Green - Slow');
+ return '#008000';
+}
+
+function calculateSpeed(point1, point2) {
+ const distanceKm = haversineDistance(point1[0], point1[1], point2[0], point2[1]); // in kilometers
+ const timeDiffSeconds = point2[4] - point1[4];
+
+ // Convert to km/h: (kilometers / seconds) * (3600 seconds / hour)
+ const speed = (distanceKm / timeDiffSeconds) * 3600;
+
+ console.log('Speed calculation:', {
+ distance: distanceKm + ' km',
+ timeDiff: timeDiffSeconds + ' seconds',
+ speed: speed + ' km/h',
+ point1: point1,
+ point2: point2
+ });
+
+ return speed;
+}
+
+export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, userSettings, distanceUnit) {
+ const highlightStyle = { opacity: 1, weight: 5 };
+ const normalStyle = { opacity: userSettings.routeOpacity, weight: 3 };
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
@@ -28,66 +67,112 @@ export function addHighlightOnHover(polyline, map, polylineCoordinates, userSett
const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
- const isDebugMode = getUrlParameter("debug") === "true";
-
- let popupContent = `
- Start: ${firstTimestamp}
- End: ${lastTimestamp}
- Duration: ${timeOnRoute}
- Total Distance: ${formatDistance(totalDistance, distanceUnit)}
- `;
-
- if (isDebugMode) {
- const prevPoint = polylineCoordinates[0];
- const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];
- const distanceToPrev = haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]);
- const distanceToNext = haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]);
-
- const timeBetweenPrev = Math.round((startPoint[4] - prevPoint[4]) / 60);
- const timeBetweenNext = Math.round((endPoint[4] - nextPoint[4]) / 60);
- const pointsNumber = polylineCoordinates.length;
-
- popupContent += `
- Prev Route: ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away
- Next Route: ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away
- Points: ${pointsNumber}
- `;
- }
-
- const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon }).bindPopup(`Start: ${firstTimestamp}`);
- const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon }).bindPopup(popupContent);
+ const startMarker = L.marker([startPoint[0], startPoint[1]], { icon: startIcon });
+ const endMarker = L.marker([endPoint[0], endPoint[1]], { icon: finishIcon });
let hoverPopup = null;
- polyline.on("mouseover", function (e) {
- polyline.setStyle(highlightStyle);
+ polylineGroup.on("mouseover", function (e) {
+ // Find the closest segment and its speed
+ let closestSegment = null;
+ let minDistance = Infinity;
+ let currentSpeed = 0;
+
+ polylineGroup.eachLayer((layer) => {
+ if (layer instanceof L.Polyline) {
+ const layerLatLngs = layer.getLatLngs();
+ const distance = L.LineUtil.pointToSegmentDistance(
+ e.latlng,
+ layerLatLngs[0],
+ layerLatLngs[1]
+ );
+
+ if (distance < minDistance) {
+ minDistance = distance;
+ closestSegment = layer;
+
+ // Get the coordinates of the segment
+ const startPoint = layerLatLngs[0];
+ const endPoint = layerLatLngs[1];
+
+ console.log('Closest segment found:', {
+ startPoint,
+ endPoint,
+ distance
+ });
+
+ // Find matching points in polylineCoordinates
+ const startIdx = polylineCoordinates.findIndex(p => {
+ const latMatch = Math.abs(p[0] - startPoint.lat) < 0.0000001;
+ const lngMatch = Math.abs(p[1] - startPoint.lng) < 0.0000001;
+ return latMatch && lngMatch;
+ });
+
+ console.log('Start point index:', startIdx);
+ console.log('Original point:', startIdx !== -1 ? polylineCoordinates[startIdx] : 'not found');
+
+ if (startIdx !== -1 && startIdx < polylineCoordinates.length - 1) {
+ currentSpeed = calculateSpeed(
+ polylineCoordinates[startIdx],
+ polylineCoordinates[startIdx + 1]
+ );
+ console.log('Speed calculated:', currentSpeed);
+ }
+ }
+ }
+ });
+
+ // Highlight all segments in the group
+ polylineGroup.eachLayer((layer) => {
+ if (layer instanceof L.Polyline) {
+ layer.setStyle({
+ ...highlightStyle,
+ color: layer.options.originalColor
+ });
+ }
+ });
+
startMarker.addTo(map);
endMarker.addTo(map);
- const latLng = e.latlng;
+ const popupContent = `
+ Start: ${firstTimestamp}
+ End: ${lastTimestamp}
+ Duration: ${timeOnRoute}
+ Total Distance: ${formatDistance(totalDistance, distanceUnit)}
+ Current Speed: ${Math.round(currentSpeed)} km/h
+ `;
+
if (hoverPopup) {
map.closePopup(hoverPopup);
}
+
hoverPopup = L.popup()
- .setLatLng(latLng)
+ .setLatLng(e.latlng)
.setContent(popupContent)
.openOn(map);
});
- polyline.on("mouseout", function () {
- polyline.setStyle(originalStyle);
- map.closePopup(hoverPopup);
+ polylineGroup.on("mouseout", function () {
+ // Restore original styles for all segments
+ polylineGroup.eachLayer((layer) => {
+ if (layer instanceof L.Polyline) {
+ layer.setStyle({
+ ...normalStyle,
+ color: layer.options.originalColor
+ });
+ }
+ });
+
+ if (hoverPopup) {
+ map.closePopup(hoverPopup);
+ }
map.removeLayer(startMarker);
map.removeLayer(endMarker);
});
- polyline.on("click", function () {
- map.fitBounds(polyline.getBounds());
- });
-
- // Close the popup when clicking elsewhere on the map
- map.on("click", function () {
- map.closePopup(hoverPopup);
+ polylineGroup.on("click", function () {
+ map.fitBounds(polylineGroup.getBounds());
});
}
@@ -97,6 +182,7 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500;
const timeThresholdMinutes = parseInt(userSettings.minutes_between_routes) || 60;
+ // Split into separate polylines based on distance/time thresholds
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
currentPolyline.push(markers[i]);
@@ -121,26 +207,45 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
return L.layerGroup(
splitPolylines.map((polylineCoordinates) => {
- const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
- const polyline = L.polyline(latLngs, {
- color: "blue",
- opacity: 0.6,
- weight: 3,
- zIndexOffset: 400,
- pane: 'overlayPane'
- });
+ const segmentGroup = L.featureGroup();
- addHighlightOnHover(polyline, map, polylineCoordinates, userSettings, distanceUnit);
+ // Create segments with different colors based on speed
+ for (let i = 0; i < polylineCoordinates.length - 1; i++) {
+ const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]);
+ const color = getSpeedColor(speed);
- return polyline;
+ const segment = L.polyline(
+ [
+ [polylineCoordinates[i][0], polylineCoordinates[i][1]],
+ [polylineCoordinates[i + 1][0], polylineCoordinates[i + 1][1]]
+ ],
+ {
+ color: color,
+ originalColor: color,
+ opacity: routeOpacity,
+ weight: 3
+ }
+ );
+
+ segmentGroup.addLayer(segment);
+ }
+
+ // Add hover effect to the entire group of segments
+ addHighlightOnHover(segmentGroup, map, polylineCoordinates, userSettings, distanceUnit);
+
+ return segmentGroup;
})
).addTo(map);
}
export function updatePolylinesOpacity(polylinesLayer, opacity) {
- polylinesLayer.eachLayer((layer) => {
- if (layer instanceof L.Polyline) {
- layer.setStyle({ opacity: opacity });
+ polylinesLayer.eachLayer((groupLayer) => {
+ if (groupLayer instanceof L.LayerGroup) {
+ groupLayer.eachLayer((segment) => {
+ if (segment instanceof L.Polyline) {
+ segment.setStyle({ opacity: opacity });
+ }
+ });
}
});
}
From 2e18b35e3cd30b712dad1c5bbfd0effe0a2b2a10 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Sat, 11 Jan 2025 00:42:44 +0100
Subject: [PATCH 30/84] Add settings for speed-colored polylines
---
app/controllers/api/v1/settings_controller.rb | 3 +-
app/javascript/controllers/maps_controller.js | 192 ++++++++------
app/javascript/maps/polylines.js | 238 +++++++++++++-----
3 files changed, 294 insertions(+), 139 deletions(-)
diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb
index f87d9df7..b15bad16 100644
--- a/app/controllers/api/v1/settings_controller.rb
+++ b/app/controllers/api/v1/settings_controller.rb
@@ -27,7 +27,8 @@ class Api::V1::SettingsController < ApiController
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
- :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
+ :immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
+ :speed_colored_polylines
)
end
end
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 028240b2..fc60bf75 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -5,8 +5,13 @@ import consumer from "../channels/consumer";
import { createMarkersArray } from "../maps/markers";
-import { createPolylinesLayer } from "../maps/polylines";
-import { updatePolylinesOpacity } from "../maps/polylines";
+import {
+ createPolylinesLayer,
+ updatePolylinesOpacity,
+ updatePolylinesColors,
+ calculateSpeed,
+ getSpeedColor
+} from "../maps/polylines";
import { fetchAndDrawAreas } from "../maps/areas";
import { handleAreaCreated } from "../maps/areas";
@@ -27,6 +32,18 @@ import { countryCodesMap } from "../maps/country_codes";
import "leaflet-draw";
+function debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+}
+
export default class extends Controller {
static targets = ["container"];
@@ -48,6 +65,7 @@ export default class extends Controller {
this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
this.liveMapEnabled = this.userSettings.live_map_enabled || false;
this.countryCodesMap = countryCodesMap();
+ this.speedColoredPolylines = this.userSettings.speed_colored_polylines || false;
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
@@ -677,6 +695,12 @@ export default class extends Controller {
+
+
`;
@@ -717,8 +741,13 @@ export default class extends Controller {
}
}
+ speedColoredPolylinesChecked() {
+ return this.userSettings.speed_colored_polylines ? 'checked' : '';
+ }
+
updateSettings(event) {
event.preventDefault();
+ console.log('Form submitted');
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
method: 'PATCH',
@@ -732,12 +761,14 @@ export default class extends Controller {
time_threshold_minutes: event.target.time_threshold_minutes.value,
merge_threshold_minutes: event.target.merge_threshold_minutes.value,
points_rendering_mode: event.target.points_rendering_mode.value,
- live_map_enabled: event.target.live_map_enabled.checked
+ live_map_enabled: event.target.live_map_enabled.checked,
+ speed_colored_polylines: event.target.speed_colored_polylines.checked
},
}),
})
.then((response) => response.json())
.then((data) => {
+ console.log('Settings update response:', data);
if (data.status === 'success') {
showFlashMessage('notice', data.message);
this.updateMapWithNewSettings(data.settings);
@@ -748,86 +779,101 @@ export default class extends Controller {
} else {
showFlashMessage('error', data.message);
}
+ })
+ .catch(error => {
+ console.error('Settings update error:', error);
+ showFlashMessage('error', 'Failed to update settings');
});
}
updateMapWithNewSettings(newSettings) {
+ console.log('Updating map settings:', {
+ newSettings,
+ currentSettings: this.userSettings,
+ hasPolylines: !!this.polylinesLayer,
+ isVisible: this.polylinesLayer && this.map.hasLayer(this.polylinesLayer)
+ });
+
+ // Store current visibility state
+ const wasPolylinesVisible = this.polylinesLayer && this.map.hasLayer(this.polylinesLayer);
const currentLayerStates = this.getLayerControlStates();
- // Update local state with new settings
- this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
- this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
+ // Show loading indicator
+ const loadingDiv = document.createElement('div');
+ loadingDiv.className = 'map-loading-overlay';
+ loadingDiv.innerHTML = '
Updating map...
';
+ document.body.appendChild(loadingDiv);
- // Preserve existing layers except polylines which need to be recreated
- const preserveLayers = {
- Points: this.markersLayer,
- Heatmap: this.heatmapLayer,
- "Fog of War": this.fogOverlay,
- Areas: this.areasLayer,
- };
+ // Debounce the heavy operations
+ const updateLayers = debounce(() => {
+ try {
+ // Check if speed_colored_polylines setting has changed
+ if (newSettings.speed_colored_polylines !== this.userSettings.speed_colored_polylines) {
+ console.log('Speed colored polylines setting changed:', {
+ old: this.userSettings.speed_colored_polylines,
+ new: newSettings.speed_colored_polylines
+ });
- // Clear all layers except base layers
- this.map.eachLayer((layer) => {
- if (!(layer instanceof L.TileLayer)) {
- this.map.removeLayer(layer);
+ if (this.polylinesLayer) {
+ console.log('Starting polylines color update');
+
+ // Update colors without removing the layer
+ this.polylinesLayer.eachLayer(groupLayer => {
+ if (groupLayer instanceof L.LayerGroup || groupLayer instanceof L.FeatureGroup) {
+ groupLayer.eachLayer(segment => {
+ if (segment instanceof L.Polyline) {
+ const latLngs = segment.getLatLngs();
+ const point1 = [latLngs[0].lat, latLngs[0].lng];
+ const point2 = [latLngs[1].lat, latLngs[1].lng];
+
+ const speed = calculateSpeed(
+ [...point1, 0, segment.options.startTime],
+ [...point2, 0, segment.options.endTime]
+ );
+
+ const newColor = newSettings.speed_colored_polylines ?
+ getSpeedColor(speed, true) :
+ '#0000ff';
+
+ segment.setStyle({
+ color: newColor,
+ originalColor: newColor
+ });
+ }
+ });
+ }
+ });
+
+ console.log('Finished polylines color update');
+ }
+ }
+
+ // Check if route opacity has changed
+ if (newSettings.route_opacity !== this.userSettings.route_opacity) {
+ const newOpacity = parseFloat(newSettings.route_opacity) || 0.6;
+ if (this.polylinesLayer) {
+ updatePolylinesOpacity(this.polylinesLayer, newOpacity);
+ }
+ }
+
+ // Update the local settings
+ this.userSettings = { ...this.userSettings, ...newSettings };
+ this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
+ this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
+
+ // Reapply layer states
+ this.applyLayerControlStates(currentLayerStates);
+
+ } catch (error) {
+ console.error('Error updating map settings:', error);
+ console.error(error.stack);
+ } finally {
+ // Remove loading indicator
+ document.body.removeChild(loadingDiv);
}
- });
+ }, 250);
- // Recreate polylines layer with new settings
- this.polylinesLayer = createPolylinesLayer(
- this.markers,
- this.map,
- this.timezone,
- this.routeOpacity,
- newSettings,
- this.distanceUnit
- );
-
- // Redraw areas
- fetchAndDrawAreas(this.areasLayer, this.apiKey);
-
- let fogEnabled = false;
- document.getElementById('fog').style.display = 'none';
-
- this.map.on('overlayadd', (e) => {
- if (e.name === 'Fog of War') {
- fogEnabled = true;
- document.getElementById('fog').style.display = 'block';
- this.updateFog(this.markers, this.clearFogRadius);
- }
- });
-
- this.map.on('overlayremove', (e) => {
- if (e.name === 'Fog of War') {
- fogEnabled = false;
- document.getElementById('fog').style.display = 'none';
- }
- });
-
- this.map.on('zoomend moveend', () => {
- if (fogEnabled) {
- this.updateFog(this.markers, this.clearFogRadius);
- }
- });
-
- this.addLastMarker(this.map, this.markers);
- this.addEventListeners();
- this.initializeDrawControl();
- updatePolylinesOpacity(this.polylinesLayer, this.routeOpacity);
-
- this.map.on('overlayadd', (e) => {
- if (e.name === 'Areas') {
- this.map.addControl(this.drawControl);
- }
- });
-
- this.map.on('overlayremove', (e) => {
- if (e.name === 'Areas') {
- this.map.removeControl(this.drawControl);
- }
- });
-
- this.applyLayerControlStates(currentLayerStates);
+ updateLayers();
}
getLayerControlStates() {
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index bd0a1595..eb9ca95f 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -4,50 +4,137 @@ import { getUrlParameter } from "../maps/helpers";
import { minutesToDaysHoursMinutes } from "../maps/helpers";
import { haversineDistance } from "../maps/helpers";
-function getSpeedColor(speedKmh) {
- console.log('Speed to color:', speedKmh + ' km/h');
+function pointToLineDistance(point, lineStart, lineEnd) {
+ const x = point.lat;
+ const y = point.lng;
+ const x1 = lineStart.lat;
+ const y1 = lineStart.lng;
+ const x2 = lineEnd.lat;
+ const y2 = lineEnd.lng;
- if (speedKmh > 100) {
- console.log('Red - Very fast');
- return '#FF0000';
+ const A = x - x1;
+ const B = y - y1;
+ const C = x2 - x1;
+ const D = y2 - y1;
+
+ const dot = A * C + B * D;
+ const lenSq = C * C + D * D;
+ let param = -1;
+
+ if (lenSq !== 0) {
+ param = dot / lenSq;
}
- if (speedKmh > 70) {
- console.log('Orange - Fast');
- return '#FFA500';
+
+ let xx, yy;
+
+ if (param < 0) {
+ xx = x1;
+ yy = y1;
+ } else if (param > 1) {
+ xx = x2;
+ yy = y2;
+ } else {
+ xx = x1 + param * C;
+ yy = y1 + param * D;
}
- if (speedKmh > 40) {
- console.log('Yellow - Moderate');
- return '#FFFF00';
- }
- if (speedKmh > 20) {
- console.log('Light green - Normal');
- return '#90EE90';
- }
- console.log('Green - Slow');
- return '#008000';
+
+ const dx = x - xx;
+ const dy = y - yy;
+
+ return Math.sqrt(dx * dx + dy * dy);
}
-function calculateSpeed(point1, point2) {
+export function calculateSpeed(point1, point2) {
const distanceKm = haversineDistance(point1[0], point1[1], point2[0], point2[1]); // in kilometers
const timeDiffSeconds = point2[4] - point1[4];
- // Convert to km/h: (kilometers / seconds) * (3600 seconds / hour)
- const speed = (distanceKm / timeDiffSeconds) * 3600;
+ // Handle edge cases
+ if (timeDiffSeconds <= 0 || distanceKm <= 0) {
+ return 0;
+ }
- console.log('Speed calculation:', {
- distance: distanceKm + ' km',
- timeDiff: timeDiffSeconds + ' seconds',
- speed: speed + ' km/h',
- point1: point1,
- point2: point2
- });
+ const speedKmh = (distanceKm / timeDiffSeconds) * 3600; // Convert to km/h
- return speed;
+ // Cap speed at reasonable maximum (e.g., 150 km/h)
+ const MAX_SPEED = 150;
+ return Math.min(speedKmh, MAX_SPEED);
+}
+
+export function getSpeedColor(speedKmh, useSpeedColors) {
+ if (!useSpeedColors) {
+ return '#0000ff'; // Default blue color
+ }
+
+ // Existing speed-based color logic
+ const colorStops = [
+ { speed: 0, color: '#00ff00' }, // Stationary/very slow (neon green)
+ { speed: 15, color: '#00ffff' }, // Walking/jogging (neon cyan)
+ { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (neon magenta)
+ { speed: 50, color: '#ff3300' }, // Urban driving (neon orange-red)
+ { speed: 100, color: '#ffff00' } // Highway driving (neon yellow)
+ ];
+
+ // Find the appropriate color segment
+ for (let i = 1; i < colorStops.length; i++) {
+ if (speedKmh <= colorStops[i].speed) {
+ // Calculate how far we are between the two speeds (0-1)
+ const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed);
+
+ // Convert hex to RGB for interpolation
+ const color1 = hexToRGB(colorStops[i-1].color);
+ const color2 = hexToRGB(colorStops[i].color);
+
+ // Interpolate between the two colors
+ const r = Math.round(color1.r + (color2.r - color1.r) * ratio);
+ const g = Math.round(color1.g + (color2.g - color1.g) * ratio);
+ const b = Math.round(color1.b + (color2.b - color1.b) * ratio);
+
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+ }
+
+ // If speed is higher than our highest threshold, return the last color
+ return colorStops[colorStops.length - 1].color;
+}
+
+// Helper function to convert hex to RGB
+function hexToRGB(hex) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return { r, g, b };
+}
+
+// Add new function for batch processing
+function processInBatches(items, batchSize, processFn) {
+ let index = 0;
+
+ function processNextBatch() {
+ const batch = items.slice(index, index + batchSize);
+ batch.forEach(processFn);
+
+ index += batchSize;
+
+ if (index < items.length) {
+ // Schedule next batch using requestAnimationFrame
+ window.requestAnimationFrame(processNextBatch);
+ }
+ }
+
+ processNextBatch();
}
export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, userSettings, distanceUnit) {
- const highlightStyle = { opacity: 1, weight: 5 };
- const normalStyle = { opacity: userSettings.routeOpacity, weight: 3 };
+ const highlightStyle = {
+ opacity: 1,
+ weight: 5,
+ color: userSettings.speed_colored_polylines ? null : '#ffff00' // Yellow highlight if not using speed colors
+ };
+ const normalStyle = {
+ opacity: userSettings.routeOpacity,
+ weight: 3,
+ color: userSettings.speed_colored_polylines ? null : '#0000ff' // Blue normal if not using speed colors
+ };
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
@@ -73,7 +160,6 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
let hoverPopup = null;
polylineGroup.on("mouseover", function (e) {
- // Find the closest segment and its speed
let closestSegment = null;
let minDistance = Infinity;
let currentSpeed = 0;
@@ -81,53 +167,33 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
polylineGroup.eachLayer((layer) => {
if (layer instanceof L.Polyline) {
const layerLatLngs = layer.getLatLngs();
- const distance = L.LineUtil.pointToSegmentDistance(
- e.latlng,
- layerLatLngs[0],
- layerLatLngs[1]
- );
+ const distance = pointToLineDistance(e.latlng, layerLatLngs[0], layerLatLngs[1]);
if (distance < minDistance) {
minDistance = distance;
closestSegment = layer;
- // Get the coordinates of the segment
- const startPoint = layerLatLngs[0];
- const endPoint = layerLatLngs[1];
-
- console.log('Closest segment found:', {
- startPoint,
- endPoint,
- distance
- });
-
- // Find matching points in polylineCoordinates
const startIdx = polylineCoordinates.findIndex(p => {
- const latMatch = Math.abs(p[0] - startPoint.lat) < 0.0000001;
- const lngMatch = Math.abs(p[1] - startPoint.lng) < 0.0000001;
+ const latMatch = Math.abs(p[0] - layerLatLngs[0].lat) < 0.0000001;
+ const lngMatch = Math.abs(p[1] - layerLatLngs[0].lng) < 0.0000001;
return latMatch && lngMatch;
});
- console.log('Start point index:', startIdx);
- console.log('Original point:', startIdx !== -1 ? polylineCoordinates[startIdx] : 'not found');
-
if (startIdx !== -1 && startIdx < polylineCoordinates.length - 1) {
currentSpeed = calculateSpeed(
polylineCoordinates[startIdx],
polylineCoordinates[startIdx + 1]
);
- console.log('Speed calculated:', currentSpeed);
}
}
}
});
- // Highlight all segments in the group
polylineGroup.eachLayer((layer) => {
if (layer instanceof L.Polyline) {
layer.setStyle({
...highlightStyle,
- color: layer.options.originalColor
+ color: userSettings.speed_colored_polylines ? layer.options.originalColor : highlightStyle.color
});
}
});
@@ -154,12 +220,11 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
});
polylineGroup.on("mouseout", function () {
- // Restore original styles for all segments
polylineGroup.eachLayer((layer) => {
if (layer instanceof L.Polyline) {
layer.setStyle({
...normalStyle,
- color: layer.options.originalColor
+ color: userSettings.speed_colored_polylines ? layer.options.originalColor : normalStyle.color
});
}
});
@@ -182,7 +247,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
const distanceThresholdMeters = parseInt(userSettings.meters_between_routes) || 500;
const timeThresholdMinutes = parseInt(userSettings.minutes_between_routes) || 60;
- // Split into separate polylines based on distance/time thresholds
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
currentPolyline.push(markers[i]);
@@ -209,10 +273,9 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
splitPolylines.map((polylineCoordinates) => {
const segmentGroup = L.featureGroup();
- // Create segments with different colors based on speed
for (let i = 0; i < polylineCoordinates.length - 1; i++) {
const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]);
- const color = getSpeedColor(speed);
+ const color = getSpeedColor(speed, userSettings.speed_colored_polylines);
const segment = L.polyline(
[
@@ -223,14 +286,15 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
color: color,
originalColor: color,
opacity: routeOpacity,
- weight: 3
+ weight: 3,
+ startTime: polylineCoordinates[i][4],
+ endTime: polylineCoordinates[i + 1][4]
}
);
segmentGroup.addLayer(segment);
}
- // Add hover effect to the entire group of segments
addHighlightOnHover(segmentGroup, map, polylineCoordinates, userSettings, distanceUnit);
return segmentGroup;
@@ -238,14 +302,58 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
).addTo(map);
}
-export function updatePolylinesOpacity(polylinesLayer, opacity) {
+export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
+ const segments = [];
+
+ // Collect all segments first
polylinesLayer.eachLayer((groupLayer) => {
if (groupLayer instanceof L.LayerGroup) {
groupLayer.eachLayer((segment) => {
if (segment instanceof L.Polyline) {
- segment.setStyle({ opacity: opacity });
+ segments.push(segment);
}
});
}
});
+
+ // Process segments in batches of 50
+ processInBatches(segments, 50, (segment) => {
+ const latLngs = segment.getLatLngs();
+ const point1 = [latLngs[0].lat, latLngs[0].lng];
+ const point2 = [latLngs[1].lat, latLngs[1].lng];
+
+ const speed = calculateSpeed(
+ [...point1, 0, segment.options.startTime],
+ [...point2, 0, segment.options.endTime]
+ );
+
+ const newColor = useSpeedColors ?
+ getSpeedColor(speed, useSpeedColors) :
+ '#0000ff';
+
+ segment.setStyle({
+ color: newColor,
+ originalColor: newColor
+ });
+ });
+}
+
+export function updatePolylinesOpacity(polylinesLayer, opacity) {
+ const segments = [];
+
+ // Collect all segments first
+ polylinesLayer.eachLayer((groupLayer) => {
+ if (groupLayer instanceof L.LayerGroup) {
+ groupLayer.eachLayer((segment) => {
+ if (segment instanceof L.Polyline) {
+ segments.push(segment);
+ }
+ });
+ }
+ });
+
+ // Process segments in batches of 50
+ processInBatches(segments, 50, (segment) => {
+ segment.setStyle({ opacity: opacity });
+ });
}
From c9d4ad1f73509d24cb4138d695d113c831c69ff9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 13 Jan 2025 15:00:52 +0000
Subject: [PATCH 31/84] Bump super_diff from 0.14.0 to 0.15.0
Bumps [super_diff](https://github.com/splitwise/super_diff) from 0.14.0 to 0.15.0.
- [Release notes](https://github.com/splitwise/super_diff/releases)
- [Changelog](https://github.com/splitwise/super_diff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/splitwise/super_diff/compare/v0.14.0...v0.15.0)
---
updated-dependencies:
- dependency-name: super_diff
dependency-type: direct:development
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
Gemfile.lock | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 7af98afd..7b9a2d69 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -391,7 +391,7 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.2)
- super_diff (0.14.0)
+ super_diff (0.15.0)
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
From 3e3f6287febe4070d93e8eb679611000964d6978 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 13 Jan 2025 15:01:03 +0000
Subject: [PATCH 32/84] Bump tailwindcss-rails from 3.1.0 to 3.2.0
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 3.1.0 to 3.2.0.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v3.1.0...v3.2.0)
---
updated-dependencies:
- dependency-name: tailwindcss-rails
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
Gemfile.lock | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/Gemfile.lock b/Gemfile.lock
index 7af98afd..c34ccfb6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -96,7 +96,7 @@ GEM
chartkick (5.1.2)
coderay (1.1.3)
concurrent-ruby (1.3.4)
- connection_pool (2.4.1)
+ connection_pool (2.5.0)
content_disposition (1.0.0)
crack (1.0.0)
bigdecimal
@@ -180,13 +180,13 @@ GEM
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.3)
- logger (1.6.4)
+ logger (1.6.5)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
- loofah (2.23.1)
+ loofah (2.24.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -259,7 +259,8 @@ GEM
raabro (1.4.0)
racc (1.8.1)
rack (3.1.8)
- rack-session (2.0.0)
+ rack-session (2.1.0)
+ base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
@@ -395,7 +396,7 @@ GEM
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
- tailwindcss-rails (3.1.0)
+ tailwindcss-rails (3.2.0)
railties (>= 7.0.0)
tailwindcss-ruby
tailwindcss-ruby (3.4.17)
From badeff3d0a4591425c8779524130c6e5a95a7207 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 13 Jan 2025 20:34:57 +0100
Subject: [PATCH 33/84] Enable or disable speed colored polylines
---
app/javascript/controllers/maps_controller.js | 79 +++++++++----------
app/javascript/maps/polylines.js | 43 +++++-----
2 files changed, 63 insertions(+), 59 deletions(-)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index fc60bf75..f6b2c749 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -809,46 +809,28 @@ export default class extends Controller {
try {
// Check if speed_colored_polylines setting has changed
if (newSettings.speed_colored_polylines !== this.userSettings.speed_colored_polylines) {
- console.log('Speed colored polylines setting changed:', {
- old: this.userSettings.speed_colored_polylines,
- new: newSettings.speed_colored_polylines
- });
-
if (this.polylinesLayer) {
- console.log('Starting polylines color update');
+ // Remove existing polylines layer
+ this.map.removeLayer(this.polylinesLayer);
- // Update colors without removing the layer
- this.polylinesLayer.eachLayer(groupLayer => {
- if (groupLayer instanceof L.LayerGroup || groupLayer instanceof L.FeatureGroup) {
- groupLayer.eachLayer(segment => {
- if (segment instanceof L.Polyline) {
- const latLngs = segment.getLatLngs();
- const point1 = [latLngs[0].lat, latLngs[0].lng];
- const point2 = [latLngs[1].lat, latLngs[1].lng];
+ // Create new polylines layer with updated settings
+ this.polylinesLayer = createPolylinesLayer(
+ this.markers,
+ this.map,
+ this.timezone,
+ this.routeOpacity,
+ { ...this.userSettings, speed_colored_polylines: newSettings.speed_colored_polylines },
+ this.distanceUnit
+ );
- const speed = calculateSpeed(
- [...point1, 0, segment.options.startTime],
- [...point2, 0, segment.options.endTime]
- );
-
- const newColor = newSettings.speed_colored_polylines ?
- getSpeedColor(speed, true) :
- '#0000ff';
-
- segment.setStyle({
- color: newColor,
- originalColor: newColor
- });
- }
- });
- }
- });
-
- console.log('Finished polylines color update');
+ // Add the layer back if it was visible
+ if (wasPolylinesVisible) {
+ this.polylinesLayer.addTo(this.map);
+ }
}
}
- // Check if route opacity has changed
+ // Update opacity if changed
if (newSettings.route_opacity !== this.userSettings.route_opacity) {
const newOpacity = parseFloat(newSettings.route_opacity) || 0.6;
if (this.polylinesLayer) {
@@ -861,8 +843,18 @@ export default class extends Controller {
this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
- // Reapply layer states
- this.applyLayerControlStates(currentLayerStates);
+ // Update layer control
+ this.map.removeControl(this.layerControl);
+ const controlsLayer = {
+ Points: this.markersLayer,
+ Polylines: this.polylinesLayer,
+ Heatmap: this.heatmapLayer,
+ "Fog of War": this.fogOverlay,
+ "Scratch map": this.scratchLayer,
+ Areas: this.areasLayer,
+ Photos: this.photoMarkers
+ };
+ this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
} catch (error) {
console.error('Error updating map settings:', error);
@@ -913,6 +905,8 @@ export default class extends Controller {
}
applyLayerControlStates(states) {
+ console.log('Applying layer states:', states);
+
const layerControl = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
@@ -923,11 +917,16 @@ export default class extends Controller {
for (const [name, isVisible] of Object.entries(states)) {
const layer = layerControl[name];
+ console.log(`Processing layer ${name}:`, { layer, isVisible });
- if (isVisible && !this.map.hasLayer(layer)) {
- this.map.addLayer(layer);
- } else if (this.map.hasLayer(layer)) {
- this.map.removeLayer(layer);
+ if (layer) {
+ if (isVisible && !this.map.hasLayer(layer)) {
+ console.log(`Adding layer ${name} to map`);
+ this.map.addLayer(layer);
+ } else if (!isVisible && this.map.hasLayer(layer)) {
+ console.log(`Removing layer ${name} from map`);
+ this.map.removeLayer(layer);
+ }
}
}
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index eb9ca95f..c13226d1 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -125,17 +125,6 @@ function processInBatches(items, batchSize, processFn) {
}
export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, userSettings, distanceUnit) {
- const highlightStyle = {
- opacity: 1,
- weight: 5,
- color: userSettings.speed_colored_polylines ? null : '#ffff00' // Yellow highlight if not using speed colors
- };
- const normalStyle = {
- opacity: userSettings.routeOpacity,
- weight: 3,
- color: userSettings.speed_colored_polylines ? null : '#0000ff' // Blue normal if not using speed colors
- };
-
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
@@ -189,12 +178,20 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
}
});
+ // Apply highlight style to all segments
polylineGroup.eachLayer((layer) => {
if (layer instanceof L.Polyline) {
- layer.setStyle({
- ...highlightStyle,
- color: userSettings.speed_colored_polylines ? layer.options.originalColor : highlightStyle.color
- });
+ const highlightStyle = {
+ weight: 5,
+ opacity: 1
+ };
+
+ // Change color to yellow only for non-speed-colored (blue) polylines
+ if (!userSettings.speed_colored_polylines) {
+ highlightStyle.color = '#ffff00'; // Yellow
+ }
+
+ layer.setStyle(highlightStyle);
}
});
@@ -220,12 +217,20 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
});
polylineGroup.on("mouseout", function () {
+ // Restore original style
polylineGroup.eachLayer((layer) => {
if (layer instanceof L.Polyline) {
- layer.setStyle({
- ...normalStyle,
- color: userSettings.speed_colored_polylines ? layer.options.originalColor : normalStyle.color
- });
+ const originalStyle = {
+ weight: 3,
+ opacity: userSettings.route_opacity
+ };
+
+ // Restore original blue color for non-speed-colored polylines
+ if (!userSettings.speed_colored_polylines) {
+ originalStyle.color = '#0000ff';
+ }
+
+ layer.setStyle(originalStyle);
}
});
From 216727b9e71d8b92163b27d61d24a6bd2e732299 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 13 Jan 2025 21:04:18 +0100
Subject: [PATCH 34/84] Fix polylines color update when settings updated
---
app/javascript/controllers/maps_controller.js | 26 ++----
app/javascript/maps/polylines.js | 83 +++++++++++--------
app/javascript/maps/popups.js | 2 +-
3 files changed, 60 insertions(+), 51 deletions(-)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index f6b2c749..5e3a88b0 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -810,23 +810,13 @@ export default class extends Controller {
// Check if speed_colored_polylines setting has changed
if (newSettings.speed_colored_polylines !== this.userSettings.speed_colored_polylines) {
if (this.polylinesLayer) {
- // Remove existing polylines layer
- this.map.removeLayer(this.polylinesLayer);
+ console.log('Starting gradual polyline color update');
- // Create new polylines layer with updated settings
- this.polylinesLayer = createPolylinesLayer(
- this.markers,
- this.map,
- this.timezone,
- this.routeOpacity,
- { ...this.userSettings, speed_colored_polylines: newSettings.speed_colored_polylines },
- this.distanceUnit
+ // Use the batch processing approach instead of recreating the layer
+ updatePolylinesColors(
+ this.polylinesLayer,
+ newSettings.speed_colored_polylines
);
-
- // Add the layer back if it was visible
- if (wasPolylinesVisible) {
- this.polylinesLayer.addTo(this.map);
- }
}
}
@@ -860,8 +850,10 @@ export default class extends Controller {
console.error('Error updating map settings:', error);
console.error(error.stack);
} finally {
- // Remove loading indicator
- document.body.removeChild(loadingDiv);
+ // Remove loading indicator after all updates are complete
+ setTimeout(() => {
+ document.body.removeChild(loadingDiv);
+ }, 500); // Give a small delay to ensure all batches are processed
}
}, 250);
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index c13226d1..4442a8ee 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -45,9 +45,21 @@ function pointToLineDistance(point, lineStart, lineEnd) {
}
export function calculateSpeed(point1, point2) {
+ if (!point1 || !point2 || !point1[4] || !point2[4]) {
+ console.warn('Invalid points for speed calculation:', { point1, point2 });
+ return 0;
+ }
+
const distanceKm = haversineDistance(point1[0], point1[1], point2[0], point2[1]); // in kilometers
const timeDiffSeconds = point2[4] - point1[4];
+ console.log('Speed calculation:', {
+ distance: distanceKm,
+ timeDiff: timeDiffSeconds,
+ point1Time: point1[4],
+ point2Time: point2[4]
+ });
+
// Handle edge cases
if (timeDiffSeconds <= 0 || distanceKm <= 0) {
return 0;
@@ -65,26 +77,22 @@ export function getSpeedColor(speedKmh, useSpeedColors) {
return '#0000ff'; // Default blue color
}
- // Existing speed-based color logic
+ // Speed-based color logic
const colorStops = [
- { speed: 0, color: '#00ff00' }, // Stationary/very slow (neon green)
- { speed: 15, color: '#00ffff' }, // Walking/jogging (neon cyan)
- { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (neon magenta)
- { speed: 50, color: '#ff3300' }, // Urban driving (neon orange-red)
- { speed: 100, color: '#ffff00' } // Highway driving (neon yellow)
+ { speed: 0, color: '#00ff00' }, // Stationary/very slow (green)
+ { speed: 15, color: '#00ffff' }, // Walking/jogging (cyan)
+ { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta)
+ { speed: 50, color: '#ff3300' }, // Urban driving (orange-red)
+ { speed: 100, color: '#ffff00' } // Highway driving (yellow)
];
// Find the appropriate color segment
for (let i = 1; i < colorStops.length; i++) {
if (speedKmh <= colorStops[i].speed) {
- // Calculate how far we are between the two speeds (0-1)
const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed);
-
- // Convert hex to RGB for interpolation
const color1 = hexToRGB(colorStops[i-1].color);
const color2 = hexToRGB(colorStops[i].color);
- // Interpolate between the two colors
const r = Math.round(color1.r + (color2.r - color1.r) * ratio);
const g = Math.round(color1.g + (color2.g - color1.g) * ratio);
const b = Math.round(color1.b + (color2.b - color1.b) * ratio);
@@ -93,7 +101,6 @@ export function getSpeedColor(speedKmh, useSpeedColors) {
}
}
- // If speed is higher than our highest threshold, return the last color
return colorStops[colorStops.length - 1].color;
}
@@ -116,8 +123,10 @@ function processInBatches(items, batchSize, processFn) {
index += batchSize;
if (index < items.length) {
- // Schedule next batch using requestAnimationFrame
- window.requestAnimationFrame(processNextBatch);
+ // Add a small delay between batches
+ setTimeout(() => {
+ window.requestAnimationFrame(processNextBatch);
+ }, 10); // 10ms delay between batches
}
}
@@ -186,9 +195,9 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
opacity: 1
};
- // Change color to yellow only for non-speed-colored (blue) polylines
+ // Only change color to yellow if speed colors are disabled
if (!userSettings.speed_colored_polylines) {
- highlightStyle.color = '#ffff00'; // Yellow
+ highlightStyle.color = '#ffff00';
}
layer.setStyle(highlightStyle);
@@ -222,14 +231,10 @@ export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, use
if (layer instanceof L.Polyline) {
const originalStyle = {
weight: 3,
- opacity: userSettings.route_opacity
+ opacity: userSettings.route_opacity,
+ color: layer.options.originalColor // Use the stored original color
};
- // Restore original blue color for non-speed-colored polylines
- if (!userSettings.speed_colored_polylines) {
- originalStyle.color = '#0000ff';
- }
-
layer.setStyle(originalStyle);
}
});
@@ -280,6 +285,11 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
for (let i = 0; i < polylineCoordinates.length - 1; i++) {
const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]);
+ console.log('Creating segment with speed:', speed, 'from points:', {
+ point1: polylineCoordinates[i],
+ point2: polylineCoordinates[i + 1]
+ });
+
const color = getSpeedColor(speed, userSettings.speed_colored_polylines);
const segment = L.polyline(
@@ -292,6 +302,7 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
originalColor: color,
opacity: routeOpacity,
weight: 3,
+ speed: speed, // Store the calculated speed
startTime: polylineCoordinates[i][4],
endTime: polylineCoordinates[i + 1][4]
}
@@ -308,6 +319,7 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
}
export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
+ console.log('Starting color update with useSpeedColors:', useSpeedColors);
const segments = [];
// Collect all segments first
@@ -321,20 +333,25 @@ export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
}
});
- // Process segments in batches of 50
- processInBatches(segments, 50, (segment) => {
- const latLngs = segment.getLatLngs();
- const point1 = [latLngs[0].lat, latLngs[0].lng];
- const point2 = [latLngs[1].lat, latLngs[1].lng];
+ console.log(`Found ${segments.length} segments to update`);
- const speed = calculateSpeed(
- [...point1, 0, segment.options.startTime],
- [...point2, 0, segment.options.endTime]
- );
+ // Process segments in smaller batches of 20
+ processInBatches(segments, 20, (segment) => {
+ if (!useSpeedColors) {
+ segment.setStyle({
+ color: '#0000ff',
+ originalColor: '#0000ff'
+ });
+ return;
+ }
- const newColor = useSpeedColors ?
- getSpeedColor(speed, useSpeedColors) :
- '#0000ff';
+ // Get the original speed from the segment options
+ const speed = segment.options.speed;
+ console.log('Segment options:', segment.options);
+ console.log('Retrieved speed:', speed);
+
+ const newColor = getSpeedColor(speed, true);
+ console.log('Calculated color for speed:', {speed, newColor});
segment.setStyle({
color: newColor,
diff --git a/app/javascript/maps/popups.js b/app/javascript/maps/popups.js
index 34a71224..dee74dc5 100644
--- a/app/javascript/maps/popups.js
+++ b/app/javascript/maps/popups.js
@@ -13,7 +13,7 @@ export function createPopupContent(marker, timezone, distanceUnit) {
Latitude: ${marker[0]} Longitude: ${marker[1]} Altitude: ${marker[3]}m
- Velocity: ${marker[5]}km/h
+ Speed: ${marker[5]}km/h Battery: ${marker[2]}% Id: ${marker[6]} [Delete]
From 7a83afd857c3fc7bd00df68cf7e12253498ecaed Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 13 Jan 2025 21:10:49 +0100
Subject: [PATCH 35/84] Speed up polylines coloring
---
app/javascript/maps/polylines.js | 64 ++++++++++++++++++--------------
1 file changed, 37 insertions(+), 27 deletions(-)
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index 4442a8ee..03a458df 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -53,13 +53,6 @@ export function calculateSpeed(point1, point2) {
const distanceKm = haversineDistance(point1[0], point1[1], point2[0], point2[1]); // in kilometers
const timeDiffSeconds = point2[4] - point1[4];
- console.log('Speed calculation:', {
- distance: distanceKm,
- timeDiff: timeDiffSeconds,
- point1Time: point1[4],
- point2Time: point2[4]
- });
-
// Handle edge cases
if (timeDiffSeconds <= 0 || distanceKm <= 0) {
return 0;
@@ -115,22 +108,43 @@ function hexToRGB(hex) {
// Add new function for batch processing
function processInBatches(items, batchSize, processFn) {
let index = 0;
+ const totalBatches = Math.ceil(items.length / batchSize);
+ let batchCount = 0;
+ const startTime = performance.now();
function processNextBatch() {
- const batch = items.slice(index, index + batchSize);
- batch.forEach(processFn);
+ const batchStartTime = performance.now();
+ let processedInThisFrame = 0;
- index += batchSize;
+ // Process multiple batches in one frame if they're taking very little time
+ while (index < items.length && processedInThisFrame < 100) {
+ const batch = items.slice(index, index + batchSize);
+ batch.forEach(processFn);
+
+ index += batchSize;
+ batchCount++;
+ processedInThisFrame += batch.length;
+
+ // If we've been processing for more than 16ms (targeting 60fps),
+ // break and schedule the next frame
+ if (performance.now() - batchStartTime > 16) {
+ break;
+ }
+ }
+
+ const batchEndTime = performance.now();
+ console.log(`Processed ${processedInThisFrame} items in ${batchEndTime - batchStartTime}ms`);
if (index < items.length) {
- // Add a small delay between batches
- setTimeout(() => {
- window.requestAnimationFrame(processNextBatch);
- }, 10); // 10ms delay between batches
+ window.requestAnimationFrame(processNextBatch);
+ } else {
+ const endTime = performance.now();
+ console.log(`All items completed in ${endTime - startTime}ms`);
}
}
- processNextBatch();
+ console.log(`Starting processing of ${items.length} items`);
+ window.requestAnimationFrame(processNextBatch);
}
export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, userSettings, distanceUnit) {
@@ -285,10 +299,6 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
for (let i = 0; i < polylineCoordinates.length - 1; i++) {
const speed = calculateSpeed(polylineCoordinates[i], polylineCoordinates[i + 1]);
- console.log('Creating segment with speed:', speed, 'from points:', {
- point1: polylineCoordinates[i],
- point2: polylineCoordinates[i + 1]
- });
const color = getSpeedColor(speed, userSettings.speed_colored_polylines);
@@ -321,6 +331,7 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
console.log('Starting color update with useSpeedColors:', useSpeedColors);
const segments = [];
+ const startCollectTime = performance.now();
// Collect all segments first
polylinesLayer.eachLayer((groupLayer) => {
@@ -333,10 +344,14 @@ export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
}
});
- console.log(`Found ${segments.length} segments to update`);
+ const endCollectTime = performance.now();
+ console.log(`Collected ${segments.length} segments in ${endCollectTime - startCollectTime}ms`);
- // Process segments in smaller batches of 20
- processInBatches(segments, 20, (segment) => {
+ // Increased batch size since individual operations are very fast
+ const BATCH_SIZE = 50;
+
+ // Process segments in batches
+ processInBatches(segments, BATCH_SIZE, (segment) => {
if (!useSpeedColors) {
segment.setStyle({
color: '#0000ff',
@@ -345,13 +360,8 @@ export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
return;
}
- // Get the original speed from the segment options
const speed = segment.options.speed;
- console.log('Segment options:', segment.options);
- console.log('Retrieved speed:', speed);
-
const newColor = getSpeedColor(speed, true);
- console.log('Calculated color for speed:', {speed, newColor});
segment.setStyle({
color: newColor,
From 1c9667d218873f11def27af738e1f0146b428061 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 13 Jan 2025 21:21:04 +0100
Subject: [PATCH 36/84] Optimize polylines color update
---
app/javascript/controllers/maps_controller.js | 3 -
app/javascript/maps/polylines.js | 121 +++++++++---------
2 files changed, 60 insertions(+), 64 deletions(-)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index 5e3a88b0..d7221d1f 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -810,9 +810,6 @@ export default class extends Controller {
// Check if speed_colored_polylines setting has changed
if (newSettings.speed_colored_polylines !== this.userSettings.speed_colored_polylines) {
if (this.polylinesLayer) {
- console.log('Starting gradual polyline color update');
-
- // Use the batch processing approach instead of recreating the layer
updatePolylinesColors(
this.polylinesLayer,
newSettings.speed_colored_polylines
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index 03a458df..3ce93c33 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -1,6 +1,5 @@
import { formatDate } from "../maps/helpers";
import { formatDistance } from "../maps/helpers";
-import { getUrlParameter } from "../maps/helpers";
import { minutesToDaysHoursMinutes } from "../maps/helpers";
import { haversineDistance } from "../maps/helpers";
@@ -65,26 +64,29 @@ export function calculateSpeed(point1, point2) {
return Math.min(speedKmh, MAX_SPEED);
}
+// Optimize getSpeedColor by pre-calculating color stops
+const colorStops = [
+ { speed: 0, color: '#00ff00' }, // Stationary/very slow (green)
+ { speed: 15, color: '#00ffff' }, // Walking/jogging (cyan)
+ { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta)
+ { speed: 50, color: '#ff3300' }, // Urban driving (orange-red)
+ { speed: 100, color: '#ffff00' } // Highway driving (yellow)
+].map(stop => ({
+ ...stop,
+ rgb: hexToRGB(stop.color)
+}));
+
export function getSpeedColor(speedKmh, useSpeedColors) {
if (!useSpeedColors) {
- return '#0000ff'; // Default blue color
+ return '#0000ff';
}
- // Speed-based color logic
- const colorStops = [
- { speed: 0, color: '#00ff00' }, // Stationary/very slow (green)
- { speed: 15, color: '#00ffff' }, // Walking/jogging (cyan)
- { speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta)
- { speed: 50, color: '#ff3300' }, // Urban driving (orange-red)
- { speed: 100, color: '#ffff00' } // Highway driving (yellow)
- ];
-
// Find the appropriate color segment
for (let i = 1; i < colorStops.length; i++) {
if (speedKmh <= colorStops[i].speed) {
const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed);
- const color1 = hexToRGB(colorStops[i-1].color);
- const color2 = hexToRGB(colorStops[i].color);
+ const color1 = colorStops[i-1].rgb;
+ const color2 = colorStops[i].rgb;
const r = Math.round(color1.r + (color2.r - color1.r) * ratio);
const g = Math.round(color1.g + (color2.g - color1.g) * ratio);
@@ -108,43 +110,40 @@ function hexToRGB(hex) {
// Add new function for batch processing
function processInBatches(items, batchSize, processFn) {
let index = 0;
- const totalBatches = Math.ceil(items.length / batchSize);
- let batchCount = 0;
- const startTime = performance.now();
+ const totalItems = items.length;
function processNextBatch() {
const batchStartTime = performance.now();
let processedInThisFrame = 0;
- // Process multiple batches in one frame if they're taking very little time
- while (index < items.length && processedInThisFrame < 100) {
- const batch = items.slice(index, index + batchSize);
- batch.forEach(processFn);
+ // Process as many items as possible within our time budget
+ while (index < totalItems && processedInThisFrame < 500) {
+ const end = Math.min(index + batchSize, totalItems);
- index += batchSize;
- batchCount++;
- processedInThisFrame += batch.length;
+ // Ensure we're within bounds
+ for (let i = index; i < end; i++) {
+ if (items[i]) { // Add null check
+ processFn(items[i]);
+ }
+ }
- // If we've been processing for more than 16ms (targeting 60fps),
- // break and schedule the next frame
- if (performance.now() - batchStartTime > 16) {
+ processedInThisFrame += (end - index);
+ index = end;
+
+ if (performance.now() - batchStartTime > 32) {
break;
}
}
- const batchEndTime = performance.now();
- console.log(`Processed ${processedInThisFrame} items in ${batchEndTime - batchStartTime}ms`);
-
- if (index < items.length) {
- window.requestAnimationFrame(processNextBatch);
+ if (index < totalItems) {
+ setTimeout(processNextBatch, 0);
} else {
- const endTime = performance.now();
- console.log(`All items completed in ${endTime - startTime}ms`);
+ // Only clear the array after all processing is complete
+ items.length = 0;
}
}
- console.log(`Starting processing of ${items.length} items`);
- window.requestAnimationFrame(processNextBatch);
+ processNextBatch();
}
export function addHighlightOnHover(polylineGroup, map, polylineCoordinates, userSettings, distanceUnit) {
@@ -329,14 +328,16 @@ export function createPolylinesLayer(markers, map, timezone, routeOpacity, userS
}
export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
- console.log('Starting color update with useSpeedColors:', useSpeedColors);
- const segments = [];
- const startCollectTime = performance.now();
+ const defaultStyle = {
+ color: '#0000ff',
+ originalColor: '#0000ff'
+ };
- // Collect all segments first
- polylinesLayer.eachLayer((groupLayer) => {
+ // More efficient segment collection
+ const segments = new Array();
+ polylinesLayer.eachLayer(groupLayer => {
if (groupLayer instanceof L.LayerGroup) {
- groupLayer.eachLayer((segment) => {
+ groupLayer.eachLayer(segment => {
if (segment instanceof L.Polyline) {
segments.push(segment);
}
@@ -344,29 +345,27 @@ export function updatePolylinesColors(polylinesLayer, useSpeedColors) {
}
});
- const endCollectTime = performance.now();
- console.log(`Collected ${segments.length} segments in ${endCollectTime - startCollectTime}ms`);
+ // Reuse style object to reduce garbage collection
+ const styleObj = {};
- // Increased batch size since individual operations are very fast
- const BATCH_SIZE = 50;
+ // Process segments in larger batches
+ processInBatches(segments, 200, (segment) => {
+ try {
+ if (!useSpeedColors) {
+ segment.setStyle(defaultStyle);
+ return;
+ }
- // Process segments in batches
- processInBatches(segments, BATCH_SIZE, (segment) => {
- if (!useSpeedColors) {
- segment.setStyle({
- color: '#0000ff',
- originalColor: '#0000ff'
- });
- return;
+ const speed = segment.options.speed || 0;
+ const newColor = getSpeedColor(speed, true);
+
+ // Reuse style object
+ styleObj.color = newColor;
+ styleObj.originalColor = newColor;
+ segment.setStyle(styleObj);
+ } catch (error) {
+ console.error('Error processing segment:', error);
}
-
- const speed = segment.options.speed;
- const newColor = getSpeedColor(speed, true);
-
- segment.setStyle({
- color: newColor,
- originalColor: newColor
- });
});
}
From cd7cf8c4bbb0e3e20a6f81d7ed819762fc232fc7 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 13 Jan 2025 21:30:08 +0100
Subject: [PATCH 37/84] Return distance and points number in the custom control
to the map
---
app/controllers/map_controller.rb | 3 ++-
app/javascript/controllers/maps_controller.js | 21 ++++++++++++++++++-
app/views/map/index.html.erb | 2 ++
3 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb
index ac960928..7a7246c5 100644
--- a/app/controllers/map_controller.rb
+++ b/app/controllers/map_controller.rb
@@ -14,6 +14,7 @@ class MapController < ApplicationController
@start_at = Time.zone.at(start_at)
@end_at = Time.zone.at(end_at)
@years = (@start_at.year..@end_at.year).to_a
+ @points_number = @coordinates.count
end
private
@@ -36,7 +37,7 @@ class MapController < ApplicationController
@distance ||= 0
@coordinates.each_cons(2) do
- @distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]])
+ @distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT)
end
@distance.round(1)
diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js
index d7221d1f..7a12d565 100644
--- a/app/javascript/controllers/maps_controller.js
+++ b/app/javascript/controllers/maps_controller.js
@@ -104,7 +104,26 @@ export default class extends Controller {
Photos: this.photoMarkers
};
- // Add scale control to bottom right
+ // Add this new custom control BEFORE the scale control
+ const TestControl = L.Control.extend({
+ onAdd: (map) => {
+ const div = L.DomUtil.create('div', 'leaflet-control');
+ const distance = this.element.dataset.distance || '0';
+ const pointsNumber = this.element.dataset.points_number || '0';
+ const unit = this.distanceUnit === 'mi' ? 'mi' : 'km';
+ div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`;
+ div.style.backgroundColor = 'white';
+ div.style.padding = '0 5px';
+ div.style.marginRight = '5px';
+ div.style.display = 'inline-block';
+ return div;
+ }
+ });
+
+ // Add the test control first
+ new TestControl({ position: 'bottomright' }).addTo(this.map);
+
+ // Then add scale control
L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb
index 7e36c225..d3c39f80 100644
--- a/app/views/map/index.html.erb
+++ b/app/views/map/index.html.erb
@@ -51,6 +51,8 @@
data-api_key="<%= current_user.api_key %>"
data-user_settings=<%= current_user.settings.to_json %>
data-coordinates="<%= @coordinates %>"
+ data-distance="<%= @distance %>"
+ data-points_number="<%= @points_number %>"
data-timezone="<%= Rails.configuration.time_zone %>">
From a1adc9875a00eb09cfa0b003d89daae65e1270a6 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 13 Jan 2025 21:37:22 +0100
Subject: [PATCH 38/84] Update changelog and app version
---
.app_version | 2 +-
CHANGELOG.md | 24 ++++++++++++++++++++++++
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/.app_version b/.app_version
index a723ece7..faa5fb26 100644
--- a/.app_version
+++ b/.app_version
@@ -1 +1 @@
-0.22.1
+0.22.2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7f7be29..641a050c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ 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.22.2 - 2025-01-13
+
+✨ The Fancy Routes release ✨
+
+### Added
+
+- In the Map Settings (coggle in the top left corner of the map), you can now enable/disable the Fancy Routes feature. Simply said, it will color your routes based on the speed of each segment.
+- Hovering over a polyline now shows the speed of the segment. Move cursor over a polyline to see the speed of different segments.
+- Distance and points number in the custom control to the map.
+
+⚠️ Important note on the Prometheus monitoring ⚠️
+
+In the previous release, `bin/dev` command in the default `docker-compose.yml` file was replaced with `bin/rails server -p 3000 -b ::`, but this way Dawarich won't be able to start Prometheus Exporter. If you want to use Prometheus monitoring, you need to use `bin/dev` command instead.
+
+Example:
+
+```diff
+ dawarich_app:
+ image: freikin/dawarich:latest
+...
+- command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
++ command: ['bin/dev']
+```
+
# 0.22.1 - 2025-01-09
### Removed
From cebc4950e61fc92365bcb8d74d886aad3823f7b9 Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Mon, 13 Jan 2025 21:57:19 +0100
Subject: [PATCH 39/84] Add info modal for speed colored polylines
---
app/javascript/maps/polylines.js | 4 ++--
app/views/map/_settings_modals.html.erb | 29 +++++++++++++++++++++++++
2 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/app/javascript/maps/polylines.js b/app/javascript/maps/polylines.js
index 3ce93c33..b2f1e94a 100644
--- a/app/javascript/maps/polylines.js
+++ b/app/javascript/maps/polylines.js
@@ -69,8 +69,8 @@ const colorStops = [
{ speed: 0, color: '#00ff00' }, // Stationary/very slow (green)
{ speed: 15, color: '#00ffff' }, // Walking/jogging (cyan)
{ speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta)
- { speed: 50, color: '#ff3300' }, // Urban driving (orange-red)
- { speed: 100, color: '#ffff00' } // Highway driving (yellow)
+ { speed: 50, color: '#ffff00' }, // Urban driving (yellow)
+ { speed: 100, color: '#ff3300' } // Highway driving (red)
].map(stop => ({
...stop,
rgb: hexToRGB(stop.color)
diff --git a/app/views/map/_settings_modals.html.erb b/app/views/map/_settings_modals.html.erb
index 09ddd165..5376a585 100644
--- a/app/views/map/_settings_modals.html.erb
+++ b/app/views/map/_settings_modals.html.erb
@@ -112,3 +112,32 @@
Close
+
+
+
+
+
Speed-colored routes
+
+ This checkbox will color the routes based on the speed of each segment.
+
+
+ Uncheck this checkbox if you want to disable the speed-colored routes.
+
+
+ Speed coloring is based on the following color stops:
+
+
+ 0 km/h — green, stationary or walking
+
+ 15 km/h — cyan, jogging
+
+ 30 km/h — magenta, cycling
+
+ 50 km/h — yellow, urban driving
+
+ 100 km/h — orange-red, highway driving
+
+