diff --git a/.app_version b/.app_version index 5c50d3ed..85e60ed1 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.30.9 +0.34.0 diff --git a/.circleci/config.yml b/.circleci/config.yml index 13f89c17..448e1708 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,7 +7,7 @@ orbs: jobs: test: docker: - - image: cimg/ruby:3.4.1-browsers + - image: cimg/ruby:3.4.6-browsers environment: RAILS_ENV: test CI: true diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6569b129..d8c8ec84 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,5 @@ # Base-Image for Ruby and Node.js -FROM ruby:3.4.1-alpine +FROM ruby:3.4.6-alpine ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 diff --git a/.env.development b/.env.development index edab341c..5cafd969 100644 --- a/.env.development +++ b/.env.development @@ -4,3 +4,6 @@ DATABASE_PASSWORD=password DATABASE_NAME=dawarich_development DATABASE_PORT=5432 REDIS_URL=redis://localhost:6379 + +# Fix for macOS fork() issues with Sidekiq +OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0a24e5a2..867f379e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,8 @@ assignees: '' --- +**BEFORE OPENING AN ISSUE, MAKE SURE YOU READ THIS: https://github.com/Freika/dawarich/issues/1382** + **OS & Hardware** Provide your software and hardware specs diff --git a/.github/workflows/build_and_push.yml b/.github/workflows/build_and_push.yml index 46244061..3c04cdb6 100644 --- a/.github/workflows/build_and_push.yml +++ b/.github/workflows/build_and_push.yml @@ -71,8 +71,8 @@ jobs: TAGS="freikin/dawarich:${VERSION}" - # Set platforms based on release type - PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7,linux/arm/v6" + # Set platforms based on version type and release type + PLATFORMS="linux/amd64,linux/arm64,linux/arm/v8,linux/arm/v7" # Add :rc tag for pre-releases if [ "${{ github.event.release.prerelease }}" = "true" ]; then diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd322ea9..b2d25cbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4.1' + ruby-version: '3.4.6' bundler-cache: true - name: Set up Node.js diff --git a/.ruby-version b/.ruby-version index 47b322c9..1cf82530 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.1 +3.4.6 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..597bf48a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- `app/` holds the Rails application: controllers and views under feature-oriented folders, `services/` for importers and background workflows, and `policies/` for Pundit authorization. +- `app/javascript/` contains Stimulus controllers (`controllers/`), map widgets (`maps/`), and Tailwind/Turbo setup in `application.js`. +- `lib/` stores reusable support code and rake tasks, while `config/` tracks environment settings, credentials, and initializers. +- `db/` carries schema migrations and data migrations; `spec/` provides RSpec coverage; `e2e/` hosts Playwright scenarios; `docker/` bundles deployment compose files. + +## Build, Test, and Development Commands +- `bundle exec rails db:prepare` initializes or migrates the PostgreSQL database. +- `bundle exec bin/dev` starts the Rails app plus JS bundler via Foreman using `Procfile.dev` (set `PROMETHEUS_EXPORTER_ENABLED=true` to use the Prometheus profile). +- `bundle exec sidekiq` runs background jobs locally alongside the web server. +- `docker compose -f docker/docker-compose.yml up` brings up the containerized stack for end-to-end smoke checks. + +## Coding Style & Naming Conventions +- Follow default Ruby style with two-space indentation and snake_case filenames; run `bin/rubocop` before pushing. +- JavaScript modules in `app/javascript/` use ES modules and Stimulus naming (`*_controller.js`); keep exports camelCase and limit files to a single controller. +- Tailwind classes power the UI; co-locate shared styles under `app/javascript/styles/` rather than inline overrides. + +## Testing Guidelines +- Use `bundle exec rspec` for unit and feature specs; mirror production behavior by tagging jobs or services with factories in `spec/support`. +- End-to-end flows live in `e2e/`; execute `npx playwright test` (set `BASE_URL` if the server runs on a non-default port). +- Commit failing scenarios together with the fix, and prefer descriptive `it "..."` strings that capture user intent. + +## Commit & Pull Request Guidelines +- Write concise, imperative commit titles (e.g., `Add family sharing policy`); group related changes rather than omnibus commits. +- Target pull requests at the `dev` branch, describe the motivation, reference GitHub issues when applicable, and attach screenshots for UI-facing changes. +- Confirm CI, lint, and test status before requesting review; call out migrations or data tasks in the PR checklist. + +## Environment & Configuration Tips +- Copy `.env.example` to `.env` or rely on Docker secrets to supply API keys, map tokens, and mail credentials. +- Regenerate credentials with `bin/rails credentials:edit` when altering secrets, and avoid committing any generated `.env` or `credentials.yml.enc` changes. diff --git a/CHANGELOG.md b/CHANGELOG.md index ea343a2a..41b47751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,147 @@ 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.34.0] - 2025-10-10 + +## The Family release + +In this release we're introducing family features that allow users to create family groups, invite members, and share location data. Family owners can manage members, control sharing settings, and ensure secure access to shared information. Location sharing is optional and can be enabled or disabled by each member individually. Users can join only one family at a time. Location sharing settings can be set to share location for 1, 6, 12, 24 hours or permanently. Family features are now available only for self-hosted instances and will be available in the cloud in the future. When "Family members" layer is enabled on the map, family member markers will be updated in real-time. + +## Added + +- Users can now create family groups and invite members to join. + +## Fixed + +- Sign out button works again. #1844 + +## Changed + +- Minor versions of Dawarich are being built for ARM64 architecture as well again. #1840 + + +# [0.33.1] - 2025-10-07 + +## Changed + +- On the Trip page, instead of list of visited countries, a number of them is being shown. Clicking on it opens a modal with a list of countries visited during the trip. #1731 + +## Fixed + +- `GET /api/v1/stats` endpoint now returns correct 0 instead of null if no points were tracked in the requested period. +- User import data now being streamed instead of loaded into memory all at once. This should prevent large imports from exhausting memory or hitting IO limits while reading export archives. +- Popup for manual visit creation now looks better in both light and dark modes. #1835 +- Fixed a bug where visit circles were not interactive on the map page. #1833 +- Fixed a bug with stats sharing settings being not filled. #1826 +- Fixed a bug where user could not be deleted due to counter cache on points. #1818 +- Introduce apt-get upgrade before installing new packages in the docker image to prevent vulnerabilities. #1793 +- Fixed time shift when creating visits manually. #1679 +- Provide default map layer if user settings are not set. + +# [0.33.0] - 2025-09-29 + +## Fixed + +- Fix a bug where some points from Owntracks were not being processed correctly which prevented import from being created. #1745 +- Hexagons for the stats page are now being calculated a lot faster. +- Prometheus exporter is now not being started when console is being run. +- Stats will now properly reflect countries and cities visited after importing new points. +- `GET /api/v1/points` will now return correct latitude and longitude values. #1502 +- Deleting an import will now trigger stats recalculation for affected months. #1789 +- Importing process should now schedule visits suggestions job a lot faster. +- Importing GPX files that start with ` 1.177.0', require: false gem 'aws-sdk-core', '~> 3.215.1', require: false gem 'aws-sdk-kms', '~> 1.96.0', require: false +gem 'aws-sdk-s3', '~> 1.177.0', require: false gem 'bootsnap', require: false gem 'chartkick' gem 'data_migrate' @@ -17,41 +17,43 @@ gem 'devise' gem 'geocoder', github: 'Freika/geocoder', branch: 'master' gem 'gpx' gem 'groupdate' +gem 'h3', '~> 3.7' gem 'httparty' gem 'importmap-rails' +gem 'jwt', '~> 2.8' gem 'kaminari' gem 'lograge' gem 'oj' gem 'parallel' gem 'pg' gem 'prometheus_exporter' -gem 'rqrcode', '~> 3.0' gem 'puma' gem 'pundit' gem 'rails', '~> 8.0' +gem 'rails_icons' gem 'redis' gem 'rexml' gem 'rgeo' gem 'rgeo-activerecord' gem 'rgeo-geojson' +gem 'rqrcode', '~> 3.0' gem 'rswag-api' gem 'rswag-ui' -gem 'rubyzip', '~> 2.4' -gem 'sentry-ruby' +gem 'rubyzip', '~> 3.1' gem 'sentry-rails' -gem 'stackprof' +gem 'sentry-ruby' gem 'sidekiq' gem 'sidekiq-cron' gem 'sidekiq-limit_fetch' gem 'sprockets-rails' +gem 'stackprof' gem 'stimulus-rails' gem 'strong_migrations' gem 'tailwindcss-rails' gem 'turbo-rails' gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] -gem 'jwt' -group :development, :test do +group :development, :test, :staging do gem 'brakeman', require: false gem 'bundler-audit', require: false gem 'debug', platforms: %i[mri mingw x64_mingw] diff --git a/Gemfile.lock b/Gemfile.lock index 74af4a35..d45a7657 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,7 +107,7 @@ GEM base64 (0.3.0) bcrypt (3.1.20) benchmark (0.4.1) - bigdecimal (3.2.2) + bigdecimal (3.2.3) bootsnap (1.18.6) msgpack (~> 1.2) brakeman (7.0.2) @@ -130,7 +130,7 @@ GEM chunky_png (1.4.0) coderay (1.1.3) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.4) crack (1.0.0) bigdecimal rexml @@ -165,14 +165,21 @@ GEM erubi (1.13.1) et-orbi (1.2.11) tzinfo - factory_bot (6.5.4) + factory_bot (6.5.5) activesupport (>= 6.1.0) - factory_bot_rails (6.5.0) + factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) fakeredis (0.1.4) - ffaker (2.24.0) - foreman (0.88.1) + ffaker (2.25.0) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86-linux-gnu) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + foreman (0.90.0) + thor (~> 1.4) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -184,6 +191,10 @@ GEM rake groupdate (6.7.0) activesupport (>= 7.1) + h3 (3.7.4) + ffi (~> 1.9) + rgeo-geojson (~> 2.1) + zeitwerk (~> 2.5) hashdiff (1.1.2) httparty (0.23.1) csv @@ -191,7 +202,7 @@ GEM multi_xml (>= 0.5.2) i18n (1.14.7) concurrent-ruby (~> 1.0) - importmap-rails (2.1.0) + importmap-rails (2.2.2) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) @@ -201,7 +212,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.12.0) + json (2.13.2) json-schema (5.0.1) addressable (~> 2.8) jwt (2.10.1) @@ -274,16 +285,20 @@ GEM orm_adapter (0.5.0) ostruct (0.6.1) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.9.0) ast (~> 2.4.1) racc patience_diff (1.2.0) optimist (~> 3.0) - pg (1.5.9) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) pp (0.6.2) prettyprint prettyprint (0.2.0) - prism (1.4.0) + prism (1.5.1) prometheus_exporter (2.2.0) webrick pry (0.15.2) @@ -304,7 +319,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.16) + rack (3.2.1) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -333,6 +348,9 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails_icons (1.4.0) + nokogiri (~> 1.16, >= 1.16.4) + rails (> 6.1) railties (8.0.2.1) actionpack (= 8.0.2.1) activesupport (= 8.0.2.1) @@ -350,7 +368,7 @@ GEM redis-client (>= 0.22.0) redis-client (0.24.0) connection_pool - regexp_parser (2.10.0) + regexp_parser (2.11.2) reline (0.6.2) io-console (~> 0.5) request_store (1.7.0) @@ -358,7 +376,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.4.1) + rexml (3.4.4) rgeo (3.0.1) rgeo-activerecord (8.0.0) activerecord (>= 7.0) @@ -398,7 +416,7 @@ GEM rswag-ui (2.16.0) actionpack (>= 5.2, < 8.1) railties (>= 5.2, < 8.1) - rubocop (1.75.6) + rubocop (1.80.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -406,26 +424,26 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.44.0, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.44.1) + rubocop-ast (1.46.0) parser (>= 3.3.7.2) prism (~> 1.4) - rubocop-rails (2.32.0) + rubocop-rails (2.33.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (1.13.0) - rubyzip (2.4.1) + rubyzip (3.1.0) securerandom (0.4.1) - selenium-webdriver (4.33.0) + selenium-webdriver (4.35.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) sentry-rails (5.26.0) railties (>= 5.0) @@ -488,9 +506,9 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode (0.4.4.5) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) uri (1.0.3) useragent (0.16.11) warden (1.2.9) @@ -539,9 +557,10 @@ DEPENDENCIES geocoder! gpx groupdate + h3 (~> 3.7) httparty importmap-rails - jwt + jwt (~> 2.8) kaminari lograge oj @@ -553,6 +572,7 @@ DEPENDENCIES puma pundit rails (~> 8.0) + rails_icons redis rexml rgeo @@ -564,7 +584,7 @@ DEPENDENCIES rswag-specs rswag-ui rubocop-rails - rubyzip (~> 2.4) + rubyzip (~> 3.1) selenium-webdriver sentry-rails sentry-ruby @@ -584,7 +604,7 @@ DEPENDENCIES webmock RUBY VERSION - ruby 3.4.1p0 + ruby 3.4.6p54 BUNDLED WITH 2.5.21 diff --git a/Procfile b/Procfile index fd4fe014..3eb630b7 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,3 @@ +release: bundle exec rails db:migrate web: bundle exec puma -C config/puma.rb worker: bundle exec sidekiq -C config/sidekiq.yml diff --git a/app.json b/app.json index 9c425d4e..fcf27c70 100644 --- a/app.json +++ b/app.json @@ -5,11 +5,6 @@ { "url": "https://github.com/heroku/heroku-buildpack-nodejs.git" }, { "url": "https://github.com/heroku/heroku-buildpack-ruby.git" } ], - "scripts": { - "dokku": { - "predeploy": "bundle exec rails db:migrate" - } - }, "healthchecks": { "web": [ { diff --git a/app/assets/builds/tailwind.css b/app/assets/builds/tailwind.css index 83fc96ab..d74cce84 100644 --- a/app/assets/builds/tailwind.css +++ b/app/assets/builds/tailwind.css @@ -1,6 +1,6 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter var,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}:root{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.89824 0.06192 275.75;--ac:0.15352 0.0368 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.4912 0.3096 275.75;--s:0.6971 0.329 342.55;--sc:0.9871 0.0106 342.55;--a:0.7676 0.184 183.61;--n:0.321785 0.02476 255.701624;--nc:0.894994 0.011585 252.096176;--b1:1 0 0;--b2:0.961151 0 0;--b3:0.924169 0.00108 197.137559;--bc:0.278078 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:0.7206 0.191 231.6;--su:64.8% 0.150 160;--wa:0.8471 0.199 83.87;--er:0.7176 0.221 22.18;--pc:0.13138 0.0392 275.75;--sc:0.1496 0.052 342.55;--ac:0.14902 0.0334 183.61;--inc:0 0 0;--suc:0 0 0;--wac:0 0 0;--erc:0 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:0.6569 0.196 275.75;--s:0.748 0.26 342.55;--a:0.7451 0.167 183.61;--n:0.313815 0.021108 254.139175;--nc:0.746477 0.0216 264.435964;--b1:0.253267 0.015896 252.417568;--b2:0.232607 0.013807 253.100675;--b3:0.211484 0.01165 254.087939;--bc:0.746477 0.0216 264.435964}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-radius:0;border-width:1px;font-size:1rem;line-height:1.5rem;padding:.5rem .75rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);border-color:#2563eb;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-bottom:0;padding-top:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;background-origin:border-box;border-color:#6b7280;border-width:1px;color:#2563eb;display:inline-block;flex-shrink:0;height:1rem;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;width:1rem;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{background-color:currentColor;background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=radio]:checked:focus,[type=radio]:checked:hover{background-color:currentColor;border-color:transparent}[type=checkbox]:indeterminate{background-color:currentColor;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-position:50%;background-repeat:no-repeat;background-size:100% 100%;border-color:transparent}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{background-color:currentColor;border-color:transparent}[type=file]{background:unset;border-color:inherit;border-radius:0;border-width:0;font-size:unset;line-height:inherit;padding:0}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.alert{align-content:flex-start;align-items:center;border-radius:var(--rounded-box,1rem);border-width:1px;display:grid;gap:1rem;grid-auto-flow:row;justify-items:center;text-align:center;width:100%;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));padding:1rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-b2,oklch(var(--b2)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1));background-color:var(--alert-bg)}@media (min-width:640px){.alert{grid-auto-flow:column;grid-template-columns:auto minmax(auto,1fr);justify-items:start;text-align:start}}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero>*{grid-column-start:1;grid-row-start:1}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.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{display:inline-flex;position:relative}.avatar>div{aspect-ratio:1/1;display:block;overflow:hidden}.avatar img{height:100%;-o-object-fit:cover;object-fit:cover;width:100%}.avatar.placeholder>div{align-items:center;display:flex;justify-content:center}.badge{align-items:center;border-radius:var(--rounded-badge,1.9rem);border-width:1px;display:inline-flex;font-size:.875rem;height:1.25rem;justify-content:center;line-height:1.25rem;padding-left:.563rem;padding-right:.563rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);width:-moz-fit-content;width:fit-content;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media (hover:hover){.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.radio-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{align-items:center;animation:button-pop var(--animation-btn,.25s) ease-out;border-color:transparent;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));border-radius:var(--rounded-btn,.5rem);border-width:var(--border-btn,1px);cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;font-weight:600;gap:.5rem;height:3rem;justify-content:center;line-height:1em;min-height:3rem;padding-left:1rem;padding-right:1rem;text-align:center;text-decoration-line:none;transition-duration:.2s;transition-property:color,background-color,border-color,opacity,box-shadow,transform;transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}.btn-circle{border-radius:9999px;height:3rem;padding:0;width:3rem}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:auto}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{border-radius:var(--rounded-box,1rem);display:flex;flex-direction:column;position:relative}.card:focus{outline:2px solid transparent;outline-offset:2px}.card-body{display:flex;flex:1 1 auto;flex-direction:column;gap:.5rem;padding:var(--padding-card,2rem)}.card-body :where(p){flex-grow:1}.card-actions{align-items:flex-start;display:flex;flex-wrap:wrap;gap:.5rem}.card figure{align-items:center;display:flex;justify-content:center}.card.image-full{display:grid}.card.image-full:before{border-radius:var(--rounded-box,1rem);content:"";position:relative;z-index:10;--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;height:1.5rem;width:1.5rem;--tw-border-opacity:0.2}:root .countdown{line-height:1em}.countdown{display:inline-flex}.countdown>*{display:inline-block;height:1em;overflow-y:hidden}.countdown>:before{content:"00\A 01\A 02\A 03\A 04\A 05\A 06\A 07\A 08\A 09\A 10\A 11\A 12\A 13\A 14\A 15\A 16\A 17\A 18\A 19\A 20\A 21\A 22\A 23\A 24\A 25\A 26\A 27\A 28\A 29\A 30\A 31\A 32\A 33\A 34\A 35\A 36\A 37\A 38\A 39\A 40\A 41\A 42\A 43\A 44\A 45\A 46\A 47\A 48\A 49\A 50\A 51\A 52\A 53\A 54\A 55\A 56\A 57\A 58\A 59\A 60\A 61\A 62\A 63\A 64\A 65\A 66\A 67\A 68\A 69\A 70\A 71\A 72\A 73\A 74\A 75\A 76\A 77\A 78\A 79\A 80\A 81\A 82\A 83\A 84\A 85\A 86\A 87\A 88\A 89\A 90\A 91\A 92\A 93\A 94\A 95\A 96\A 97\A 98\A 99\A";position:relative;text-align:center;top:calc(var(--value)*-1em);transition:all 1s cubic-bezier(1,0,0,1);white-space:pre}.divider{align-items:center;align-self:stretch;display:flex;flex-direction:row;height:1rem;margin-bottom:1rem;margin-top:1rem;white-space:nowrap}.divider:after,.divider:before{flex-grow:1;height:.125rem;width:100%;--tw-content:"";background-color:var(--fallback-bc,oklch(var(--bc)/.1));content:var(--tw-content)}.\!drawer{display:grid!important;grid-auto-columns:max-content auto!important;position:relative!important;width:100%!important}.drawer{display:grid;grid-auto-columns:max-content auto;position:relative;width:100%}.dropdown{display:inline-block;position:relative}.dropdown>:not(summary):focus{outline:2px solid transparent;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{opacity:0;transform-origin:top;visibility:hidden;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{opacity:1;visibility:visible}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{opacity:1;visibility:visible}.btm-nav>.disabled:hover,.btm-nav>[disabled]:hover{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.btn-ghost:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid transparent;outline-offset:2px}@supports (color:oklch(0 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.file-input{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;overflow:hidden;padding-inline-end:1rem;--tw-border-opacity:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.file-input::file-selector-button{align-items:center;border-style:solid;cursor:pointer;display:inline-flex;flex-shrink:0;flex-wrap:wrap;font-size:.875rem;height:100%;justify-content:center;line-height:1.25rem;line-height:1em;margin-inline-end:1rem;padding-left:1rem;padding-right:1rem;text-align:center;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));font-weight:600;text-transform:uppercase;--tw-text-opacity:1;animation:button-pop var(--animation-btn,.25s) ease-out;border-width:var(--border-btn,1px);color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));text-decoration-line:none}.footer{-moz-column-gap:1rem;column-gap:1rem;font-size:.875rem;grid-auto-flow:row;line-height:1.25rem;row-gap:2.5rem;width:100%}.footer,.footer>*{display:grid;place-items:start}.footer>*{gap:.5rem}@media (min-width:48rem){.footer{grid-auto-flow:column}.footer-center{grid-auto-flow:row dense}}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{align-items:center;justify-content:space-between;padding:.5rem .25rem;-webkit-user-select:none;-moz-user-select:none;user-select:none}.hero{background-position:50%;background-size:cover;display:grid;place-items:center;width:100%}.hero-overlay,.hero>*{grid-column-start:1;grid-row-start:1}.hero-overlay{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));height:100%;width:100%;--tw-bg-opacity:0.5}.hero-content{align-items:center;display:flex;gap:1rem;justify-content:center;max-width:80rem;padding:1rem;z-index:0}.indicator{display:inline-flex;position:relative;width:-moz-max-content;width:max-content}.indicator :where(.indicator-item){position:absolute;white-space:nowrap;z-index:1}.input{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;flex-shrink:1;font-size:1rem;height:3rem;line-height:2;line-height:1.5rem;padding-left:1rem;padding-right:1rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-bottom:-1rem;margin-top:-1rem;margin-inline-end:-1rem}.input-xs[type=number]::-webkit-inner-spin-button{margin-bottom:-.25rem;margin-top:-.25rem;margin-inline-end:0}.input-sm[type=number]::-webkit-inner-spin-button{margin-bottom:0;margin-top:0;margin-inline-end:0}.join{align-items:stretch;border-radius:var(--rounded-btn,.5rem);display:inline-flex}.join :where(.join-item){border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-end-end-radius:inherit;border-start-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-end-end-radius:inherit;border-start-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){margin-inline-start:1rem;padding-inline-start:.5rem;position:relative;white-space:nowrap}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){align-content:flex-start;align-items:center;display:grid;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{color:var(--fallback-bc,oklch(var(--bc)/.3));cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){align-items:stretch;display:flex;flex-direction:column;flex-shrink:0;flex-wrap:wrap;position:relative}:where(.menu li) .badge{justify-self:end}.modal{background-color:transparent;color:inherit;display:grid;height:100%;inset:0;justify-items:center;margin:0;max-height:none;max-width:none;opacity:0;overflow-y:hidden;overscroll-behavior:contain;padding:0;pointer-events:none;position:fixed;transition-duration:.2s;transition-property:transform,opacity,visibility;transition-timing-function:cubic-bezier(0,0,.2,1);width:100%;z-index:999}:where(.modal){align-items:center}.modal-box{grid-column-start:1;grid-row-start:1;max-height:calc(100vh - 5em);max-width:32rem;width:91.666667%;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));box-shadow:0 25px 50px -12px rgba(0,0,0,.25);overflow-y:auto;overscroll-behavior:contain;padding:1.5rem;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}.modal-open,.modal-toggle:checked+.modal,.modal:target,.modal[open]{opacity:1;pointer-events:auto;visibility:visible}.modal-action{display:flex;justify-content:flex-end;margin-top:1.5rem}.modal-toggle{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:0;opacity:0;position:fixed;width:0}:root:has(:is(.modal-open,.modal:target,.modal-toggle:checked+.modal,.modal[open])){overflow:hidden}.navbar{align-items:center;display:flex;min-height:4rem;padding:var(--navbar-padding,.5rem);width:100%}:where(.navbar>:not(script,style)){align-items:center;display:inline-flex}.navbar-start{justify-content:flex-start;width:50%}.navbar-center{flex-shrink:0}.navbar-end{justify-content:flex-end;width:50%}.progress{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-radius:var(--rounded-box,1rem);height:.5rem;overflow:hidden;position:relative;width:100%}.radio{flex-shrink:0;--chkbg:var(--bc);-webkit-appearance:none;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));border-radius:9999px;border-width:1px;width:1.5rem;--tw-border-opacity:0.2}.radio,.range{-moz-appearance:none;appearance:none;cursor:pointer;height:1.5rem}.range{-webkit-appearance:none;width:100%;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));background-color:transparent;border-radius:var(--rounded-box,1rem);overflow:hidden}.range:focus{outline:none}.select{-webkit-appearance:none;-moz-appearance:none;appearance:none;border-color:transparent;border-radius:var(--rounded-btn,.5rem);border-width:1px;cursor:pointer;display:inline-flex;font-size:.875rem;height:3rem;line-height:1.25rem;line-height:2;min-height:3rem;padding-left:1rem;padding-right:2.5rem;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,transparent 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,transparent 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;opacity:.6;transform:translateY(10%) scale(.9);width:100%;z-index:1}.stack>:nth-child(2){opacity:.8;transform:translateY(5%) scale(.95);z-index:2}.stack>:first-child{opacity:1;transform:translateY(0) scale(1);z-index:3}.stats{border-radius:var(--rounded-box,1rem);display:inline-grid;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}:where(.stats){grid-auto-flow:column;overflow-x:auto}.stat{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));-moz-column-gap:1rem;column-gap:1rem;display:inline-grid;grid-template-columns:repeat(1,1fr);width:100%;--tw-border-opacity:0.1;padding:1rem 1.5rem}.stat-title{color:var(--fallback-bc,oklch(var(--bc)/.6))}.stat-title,.stat-value{grid-column-start:1;white-space:nowrap}.stat-value{font-size:2.25rem;font-weight:800;line-height:2.5rem}.stat-desc{color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;grid-column-start:1;line-height:1rem;white-space:nowrap}.steps .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-columns:auto;grid-template-rows:repeat(2,minmax(0,1fr));grid-template-rows:40px 1fr;min-width:4rem;place-items:center;text-align:center}.tabs{align-items:flex-end;display:grid}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(.tab-active),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(.tab-active){border-bottom-color:transparent}.tab{align-items:center;-webkit-appearance:none;-moz-appearance:none;appearance:none;cursor:pointer;display:inline-flex;flex-wrap:wrap;font-size:.875rem;grid-row-start:1;height:2rem;justify-content:center;line-height:1.25rem;line-height:2;position:relative;text-align:center;-webkit-user-select:none;-moz-user-select:none;user-select:none;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem)}.tab:is(input[type=radio]){border-bottom-left-radius:0;border-bottom-right-radius:0;width:auto}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-active+.tab-content:nth-child(2),:checked+.tab-content:nth-child(2){border-start-start-radius:0}.tab-active+.tab-content,input.tab:checked+.tab-content{display:block}.table{border-radius:var(--rounded-box,1rem);font-size:.875rem;line-height:1.25rem;position:relative;text-align:left;width:100%}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){bottom:0;position:sticky;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){left:0;position:sticky;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.timeline{display:flex;position:relative}:where(.timeline>li){align-items:center;display:grid;flex-shrink:0;grid-template-columns:var(--timeline-col-start,minmax(0,1fr)) auto var( --timeline-col-end,minmax(0,1fr) );grid-template-rows:var(--timeline-row-start,minmax(0,1fr)) auto var( --timeline-row-end,minmax(0,1fr) - );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-primary,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}}}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-sm{font-size:.875rem;height:2rem;line-height:2rem;min-height:2rem;padding-left:.75rem;padding-right:2rem}[dir=rtl] .select-sm{padding-left:2rem;padding-right:.75rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.left-2{left:.5rem}.right-0{right:0}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.m-0{margin:0}.m-2{margin:.5rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10\/12{width:83.333333%}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-5xl{max-width:64rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-opacity-30{--tw-border-opacity:0.3}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.grayscale{--tw-grayscale:grayscale(100%)}.filter,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-control-button{background-color:#fff!important;color:#374151!important}.leaflet-control-button:hover{background-color:#f3f4f6!important}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact -.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-blue-500\/50:hover{--tw-shadow-color:rgba(59,130,246,.5);--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\: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\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (prefers-color-scheme:dark){.dark\:bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}} \ No newline at end of file + );position:relative}.timeline>li>hr{border-width:0;width:100%}:where(.timeline>li>hr):first-child{grid-column-start:1;grid-row-start:2}:where(.timeline>li>hr):last-child{grid-column-end:none;grid-column-start:3;grid-row-end:auto;grid-row-start:2}.timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center;margin:.25rem}.timeline-middle{grid-column-start:2;grid-row-start:2}.timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.toast{display:flex;flex-direction:column;gap:.5rem;min-width:-moz-fit-content;min-width:fit-content;padding:1rem;position:fixed;white-space:nowrap}.toggle{flex-shrink:0;--tglbg:var(--fallback-b1,oklch(var(--b1)/1));--handleoffset:1.5rem;--handleoffsetcalculator:calc(var(--handleoffset)*-1);--togglehandleborder:0 0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:currentColor;border-color:currentColor;border-radius:var(--rounded-badge,1.9rem);border-width:1px;box-shadow:var(--handleoffsetcalculator) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset,var(--togglehandleborder);color:var(--fallback-bc,oklch(var(--bc)/.5));cursor:pointer;height:1.5rem;transition:background,box-shadow var(--animation-input,.2s) ease-out;width:3rem}.alert-info{border-color:var(--fallback-in,oklch(var(--in)/.2));--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-in,oklch(var(--in)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-success{border-color:var(--fallback-su,oklch(var(--su)/.2));--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-su,oklch(var(--su)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-warning{border-color:var(--fallback-wa,oklch(var(--wa)/.2));--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));--alert-bg:var(--fallback-wa,oklch(var(--wa)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.alert-error{border-color:var(--fallback-er,oklch(var(--er)/.2));--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));--alert-bg:var(--fallback-er,oklch(var(--er)/1));--alert-bg-mix:var(--fallback-b1,oklch(var(--b1)/1))}.avatar-group :where(.avatar){border-radius:9999px;border-width:4px;overflow:hidden;--tw-border-opacity:1;border-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-border-opacity)))}.badge-neutral{background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-secondary{background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)));border-color:var(--fallback-s,oklch(var(--s)/var(--tw-border-opacity)));color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.badge-accent,.badge-secondary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-accent{background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)));border-color:var(--fallback-a,oklch(var(--a)/var(--tw-border-opacity)));color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.badge-success{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)));color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.badge-success,.badge-warning{border-color:transparent;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-warning{background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)));color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline{border-color:currentColor;--tw-border-opacity:0.5;background-color:transparent;color:currentColor}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.badge-outline.badge-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.badge-outline.badge-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.badge-outline.badge-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.badge-outline.badge-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.badge-outline.badge-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.badge-outline.badge-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btm-nav>:where(.active){border-top-width:2px;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.btm-nav>.disabled,.btm-nav>[disabled]{pointer-events:none;--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-primary{--btn-color:var(--fallback-p)}.btn-neutral{--btn-color:var(--fallback-n)}.btn-info{--btn-color:var(--fallback-in)}.btn-success{--btn-color:var(--fallback-su)}.btn-warning{--btn-color:var(--fallback-wa)}.btn-error{--btn-color:var(--fallback-er)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-offset:2px;outline-style:solid;outline-width:2px}.btn-primary{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}@supports (color:oklch(0 0 0)){.btn-primary{--btn-color:var(--p)}.btn-neutral{--btn-color:var(--n)}.btn-info{--btn-color:var(--in)}.btn-success{--btn-color:var(--su)}.btn-warning{--btn-color:var(--wa)}.btn-error{--btn-color:var(--er)}}.btn-neutral{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)));outline-color:var(--fallback-n,oklch(var(--n)/1))}.btn-info{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.btn-success{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)));outline-color:var(--fallback-su,oklch(var(--su)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn-error{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn-ghost.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.btn-link.btn-active{background-color:transparent;border-color:transparent;text-decoration-line:underline}.btn-outline{background-color:transparent;border-color:currentColor;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){border-end-end-radius:unset;border-end-start-radius:unset;border-start-end-radius:inherit;border-start-start-radius:inherit;overflow:hidden}.card :where(figure:last-child){border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:unset;border-start-start-radius:unset;overflow:hidden}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-title{align-items:center;display:flex;font-size:1.25rem;font-weight:600;gap:.5rem;line-height:1.75rem}.card.image-full :where(figure){border-radius:inherit;overflow:hidden}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.checkbox:checked,.checkbox[aria-checked=true],.checkbox[checked=true]{animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--chkbg);background-image:linear-gradient(-45deg,transparent 65%,var(--chkbg) 65.99%),linear-gradient(45deg,transparent 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,transparent 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,transparent 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%);background-repeat:no-repeat}.checkbox:indeterminate{--tw-bg-opacity:1;animation:checkmark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(-90deg,transparent 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%);background-repeat:no-repeat}.checkbox:disabled{border-color:transparent;cursor:not-allowed;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.drawer-toggle:focus-visible~.drawer-content label.drawer-button{outline-offset:2px;outline-style:solid;outline-width:2px}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.file-input-bordered{--tw-border-opacity:0.2}.file-input:focus{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.file-input-disabled,.file-input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:0.2}.file-input-disabled::-moz-placeholder,.file-input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::placeholder,.file-input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.file-input-disabled::file-selector-button,.file-input[disabled]::file-selector-button{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.footer-title{font-weight:700;margin-bottom:.5rem;opacity:.6;text-transform:uppercase}.label-text{font-size:.875rem;line-height:1.25rem}.label-text,.label-text-alt{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.label-text-alt{font-size:.75rem;line-height:1rem}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:transparent}.input input:focus{outline:2px solid transparent;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2));box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.input-ghost{--tw-bg-opacity:0.05}.input-ghost:focus,.input-ghost:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input-primary{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.input-primary:focus,.input-primary:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.input-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)))}.input-error:focus,.input-error:focus-within{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity)));outline-color:var(--fallback-er,oklch(var(--er)/1))}.input-disabled,.input:disabled,.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.join-item:focus{isolation:isolate}.link-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){@media (hover:hover){.link-primary:hover{color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 80%,#000)}.link-info:hover{color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 80%,#000)}}}.link-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.link:focus{outline:2px solid transparent;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}.loading{aspect-ratio:1/1;background-color:currentColor;display:inline-block;-webkit-mask-position:center;mask-position:center;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100%;mask-size:100%;pointer-events:none;width:1.5rem}.loading,.loading-spinner{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' stroke='%23000'%3E%3Cstyle%3E@keyframes spinner_zKoa{to{transform:rotate(360deg)}}@keyframes spinner_YpZS{0%25{stroke-dasharray:0 150;stroke-dashoffset:0}47.5%25{stroke-dasharray:42 150;stroke-dashoffset:-16}95%25,to{stroke-dasharray:42 150;stroke-dashoffset:-59}}%3C/style%3E%3Cg style='transform-origin:center;animation:spinner_zKoa 2s linear infinite'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' class='spinner_V8m1' style='stroke-linecap:round;animation:spinner_YpZS 1.5s ease-out infinite'/%3E%3C/g%3E%3C/svg%3E")}.loading-dots{-webkit-mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cstyle%3E@keyframes spinner_8HQG{0%25,57.14%25{animation-timing-function:cubic-bezier(.33,.66,.66,1);transform:translate(0)}28.57%25{animation-timing-function:cubic-bezier(.33,0,.66,.33);transform:translateY(-6px)}to{transform:translate(0)}}.spinner_qM83{animation:spinner_8HQG 1.05s infinite}%3C/style%3E%3Ccircle cx='4' cy='12' r='3' class='spinner_qM83'/%3E%3Ccircle cx='12' cy='12' r='3' class='spinner_qM83' style='animation-delay:.1s'/%3E%3Ccircle cx='20' cy='12' r='3' class='spinner_qM83' style='animation-delay:.2s'/%3E%3C/svg%3E")}.loading-sm{width:1.25rem}.loading-md{width:1.5rem}.loading-lg{width:2.5rem}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));height:1px;margin:.5rem 1rem;opacity:.1}.menu :where(li ul):before{bottom:.75rem;inset-inline-start:0;position:absolute;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));content:"";opacity:.1}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;text-wrap:balance;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1)}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{background-color:var(--fallback-bc,oklch(var(--bc)/.1));cursor:pointer;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid transparent;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{box-shadow:2px 2px;content:"";display:block;height:.5rem;justify-self:end;margin-top:-.5rem;pointer-events:none;transform:rotate(45deg);transform-origin:75% 75%;transition-duration:.3s;transition-property:transform,margin-top;transition-timing-function:cubic-bezier(.4,0,.2,1);width:.5rem}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{margin-top:0;transform:rotate(225deg)}.mockup-phone .camera{background:#000;border-bottom-left-radius:17px;border-bottom-right-radius:17px;height:25px;left:0;margin:0 auto;position:relative;top:0;width:150px;z-index:11}.mockup-phone .camera:before{background-color:#0c0b0e;border-radius:5px;content:"";height:4px;left:50%;position:absolute;top:35%;transform:translate(-50%,-50%);width:50px}.mockup-phone .camera:after{background-color:#0f0b25;border-radius:5px;content:"";height:8px;left:70%;position:absolute;top:20%;width:8px}.mockup-phone .display{border-radius:40px;margin-top:-25px;overflow:hidden}.mockup-browser .mockup-browser-toolbar .input{display:block;height:1.75rem;margin-left:auto;margin-right:auto;overflow:hidden;position:relative;text-overflow:ellipsis;white-space:nowrap;width:24rem;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));direction:ltr;padding-left:2rem}.mockup-browser .mockup-browser-toolbar .input:before{aspect-ratio:1/1;height:.75rem;left:.5rem;--tw-translate-y:-50%;border-color:currentColor;border-radius:9999px;border-width:2px}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";opacity:.6;position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.mockup-browser .mockup-browser-toolbar .input:after{height:.5rem;left:1.25rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-color:currentColor;border-radius:9999px;border-width:1px}.modal::backdrop,.modal:not(dialog:not(.modal-open)){animation:modal-pop .2s ease-out;background-color:#0006}.modal-backdrop{align-self:stretch;color:transparent;display:grid;grid-column-start:1;grid-row-start:1;justify-self:stretch;z-index:-1}.modal-open .modal-box,.modal-toggle:checked+.modal .modal-box,.modal:target .modal-box,.modal[open] .modal-box{--tw-translate-y:0px;--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-action>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}@keyframes modal-pop{0%{opacity:0}}.progress::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-moz-progress-bar{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate{--progress-color:var(--fallback-bc,oklch(var(--bc)/1));animation:progress-loading 5s ease-in-out infinite;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}.progress-primary:indeterminate{--progress-color:var(--fallback-p,oklch(var(--p)/1))}.progress-secondary:indeterminate{--progress-color:var(--fallback-s,oklch(var(--s)/1))}.progress-accent:indeterminate{--progress-color:var(--fallback-a,oklch(var(--a)/1))}.progress-info:indeterminate{--progress-color:var(--fallback-in,oklch(var(--in)/1))}.progress-success:indeterminate{--progress-color:var(--fallback-su,oklch(var(--su)/1))}.progress-warning:indeterminate{--progress-color:var(--fallback-wa,oklch(var(--wa)/1))}.progress::-webkit-progress-bar{background-color:transparent;border-radius:var(--rounded-box,1rem)}.progress::-webkit-progress-value{border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)))}.progress-primary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)))}.progress-secondary::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity)))}.progress-accent::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity)))}.progress-info::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity)))}.progress-success::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.progress-warning::-webkit-progress-value{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity)))}.progress:indeterminate::-moz-progress-bar{animation:progress-loading 5s ease-in-out infinite;background-color:transparent;background-image:repeating-linear-gradient(90deg,var(--progress-color) -1%,var(--progress-color) 10%,transparent 10%,transparent 90%);background-position-x:15%;background-size:200%}@keyframes progress-loading{50%{background-position-x:-115%}}.radio:focus{box-shadow:none}.radio:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/1));outline-offset:2px;outline-style:solid;outline-width:2px}.radio:checked,.radio[aria-checked=true]{--tw-bg-opacity:1;animation:radiomark var(--animation-input,.2s) ease-out;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:none;box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}.radio-primary{--chkbg:var(--p);--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.radio-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.radio-primary:checked,.radio-primary[aria-checked=true]{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.radio:disabled{cursor:not-allowed;opacity:.2}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-moz-range-track{background-color:var(--fallback-bc,oklch(var(--bc)/.1));border-radius:var(--rounded-box,1rem);height:.5rem;width:100%}.range::-webkit-slider-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;appearance:none;-webkit-appearance:none;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{border-radius:var(--rounded-box,1rem);border-style:none;height:1.5rem;position:relative;width:1.5rem;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));color:var(--range-shdw);top:50%;--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select-bordered,.select:focus{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select:focus{box-shadow:none;outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}:where(.stats)>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}:is([dir=rtl] .stats>:not([hidden])~:not([hidden])){--tw-divide-x-reverse:1}.steps .step:before{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:"";height:.5rem;margin-inline-start:-100%;top:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));width:100%}.steps .step:after,.steps .step:before{grid-column-start:1;grid-row-start:1;--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)));--tw-text-opacity:1}.steps .step:after{border-radius:9999px;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));content:counter(step);counter-increment:step;display:grid;height:2rem;place-items:center;place-self:center;position:relative;width:2rem;z-index:1}.steps .step:first-child:before{content:none}.steps .step[data-content]:after{content:attr(data-content)}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab.tab-active:not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid transparent;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));cursor:not-allowed;--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-bottom-width:calc(var(--tab-border, 1px) + 1px);border-style:solid}.tabs-lifted>.tab{border:var(--tab-border,1px) solid transparent;border-bottom-color:var(--tab-border-color);border-start-end-radius:var(--tab-radius,.5rem);border-start-start-radius:var(--tab-radius,.5rem);border-width:0 0 var(--tab-border,1px) 0;padding-inline-end:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-inline-end-color:var(--tab-border-color);border-inline-start-color:var(--tab-border-color);border-top-color:var(--tab-border-color);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-top:0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{background-position:0 0,100% 0;background-repeat:no-repeat;background-size:var(--tab-radius,.5rem);bottom:0;content:"";display:block;height:var(--tab-radius,.5rem);position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);z-index:1;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,transparent var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-start);background-position:0 0}[dir=rtl] .tabs-lifted>.tab.tab-active:not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab-active:not(.tab-disabled):not([disabled])+.tabs-lifted .tab-active:not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding:.25rem}.tabs-boxed,.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed .tab-active:not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}:is([dir=rtl] .table){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead,tbody) :where(tr:first-child:last-child),.table :where(thead,tbody) :where(tr:not(:last-child)){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){color:var(--fallback-bc,oklch(var(--bc)/.6));font-size:.75rem;font-weight:700;line-height:1rem;white-space:nowrap}.timeline hr{height:.25rem}:where(.timeline hr){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}:where(.timeline:has(.timeline-middle) hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline:has(.timeline-middle) hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :first-child hr:last-child){border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}:where(.timeline:not(:has(.timeline-middle)) :last-child hr:first-child){border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}.timeline-box{border-radius:var(--rounded-box,1rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));padding:.5rem 1rem;--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.toast>*{animation:toast-pop .25s ease-out}@keyframes toast-pop{0%{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}[dir=rtl] .toggle{--handleoffsetcalculator:calc(var(--handleoffset)*1)}.toggle:focus-visible{outline-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-offset:2px;outline-style:solid;outline-width:2px}.toggle:hover{background-color:currentColor}.toggle:checked,.toggle[aria-checked=true],.toggle[checked=true]{background-image:none;--handleoffsetcalculator:var(--handleoffset);--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:checked,[dir=rtl] .toggle[aria-checked=true],[dir=rtl] .toggle[checked=true]{--handleoffsetcalculator:calc(var(--handleoffset)*-1)}.toggle:indeterminate{--tw-text-opacity:1;box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}[dir=rtl] .toggle:indeterminate{box-shadow:calc(var(--handleoffset)/2) 0 0 2px var(--tglbg) inset,calc(var(--handleoffset)/-2) 0 0 2px var(--tglbg) inset,0 0 0 2px var(--tglbg) inset}.toggle-primary:focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}.toggle-primary:checked,.toggle-primary[aria-checked=true],.toggle-primary[checked=true]{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-border-opacity:0.1;--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.toggle:disabled{cursor:not-allowed;--tw-border-opacity:1;background-color:transparent;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));opacity:.3;--togglehandleborder:0 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset,var(--handleoffsetcalculator) 0 0 3px var(--fallback-bc,oklch(var(--bc)/1)) inset}.glass,.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:transparent;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,transparent 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,transparent 25%);border:none;box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px rgb(0 0 0/5%);text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{font-size:.75rem;height:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.badge-sm{font-size:.75rem;height:1rem;line-height:1rem;padding-left:.438rem;padding-right:.438rem}.badge-lg{font-size:1rem;height:1.5rem;line-height:1.5rem;padding-left:.688rem;padding-right:.688rem}.btm-nav-xs>:where(.active){border-top-width:1px}.btm-nav-sm>:where(.active){border-top-width:2px}.btm-nav-md>:where(.active){border-top-width:2px}.btm-nav-lg>:where(.active){border-top-width:4px}.btn-xs{font-size:.75rem;height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem}.btn-sm{font-size:.875rem;height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem}.btn-lg{font-size:1.125rem;height:4rem;min-height:4rem;padding-left:1.5rem;padding-right:1.5rem}.btn-wide{width:16rem}.btn-square:where(.btn-xs){height:1.5rem;padding:0;width:1.5rem}.btn-square:where(.btn-sm){height:2rem;padding:0;width:2rem}.btn-square:where(.btn-lg){height:4rem;padding:0;width:4rem}.btn-circle:where(.btn-xs){border-radius:9999px;height:1.5rem;padding:0;width:1.5rem}.btn-circle:where(.btn-sm){border-radius:9999px;height:2rem;padding:0;width:2rem}.btn-circle:where(.btn-md){border-radius:9999px;height:3rem;padding:0;width:3rem}.btn-circle:where(.btn-lg){border-radius:9999px;height:4rem;padding:0;width:4rem}[type=checkbox].checkbox-sm{height:1.25rem;width:1.25rem}.indicator :where(.indicator-item){bottom:auto;inset-inline-end:0;inset-inline-start:auto;top:0;--tw-translate-y:-50%;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-start)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .indicator :where(.indicator-item.indicator-end)){--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-bottom){bottom:0;top:auto;--tw-translate-y:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-middle){bottom:50%;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.indicator :where(.indicator-item.indicator-top){bottom:auto;top:0;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.input-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;padding-left:.5rem;padding-right:.5rem}.input-sm{font-size:.875rem;height:2rem;line-height:2rem;padding-left:.75rem;padding-right:.75rem}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:0}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-end-start-radius:inherit;border-start-end-radius:0;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-end-radius:inherit;border-end-start-radius:0;border-start-end-radius:inherit;border-start-start-radius:0}.menu-horizontal{display:inline-flex;flex-direction:row}.menu-horizontal>li:not(.menu-title)>details>ul{position:absolute}.select-xs{font-size:.75rem;height:1.5rem;line-height:1rem;line-height:1.625;min-height:1.5rem;padding-left:.5rem;padding-right:2rem}[dir=rtl] .select-xs{padding-left:2rem;padding-right:.5rem}.stats-vertical{grid-auto-flow:row}.steps-horizontal .step{display:grid;grid-template-columns:repeat(1,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr));place-items:center;text-align:center}.steps-vertical .step{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(1,minmax(0,1fr))}.tabs-md :where(.tab){font-size:.875rem;height:2rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){font-size:1.125rem;height:3rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){font-size:.875rem;height:1.5rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){font-size:.75rem;height:1.25rem;line-height:.75rem;--tab-padding:0.5rem}.timeline-vertical{flex-direction:column}.timeline-compact .timeline-start,.timeline-horizontal.timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.timeline-compact li:has(.timeline-start) .timeline-end,.timeline-horizontal.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.timeline-vertical.timeline-compact>li{--timeline-col-start:0}.timeline-vertical.timeline-compact .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical.timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}:where(.timeline-vertical>li){--timeline-row-start:minmax(0,1fr);--timeline-row-end:minmax(0,1fr);justify-items:center}.timeline-vertical>li>hr{height:100%}:where(.timeline-vertical>li>hr):first-child{grid-column-start:2;grid-row-start:1}:where(.timeline-vertical>li>hr):last-child{grid-column-end:auto;grid-column-start:2;grid-row-end:none;grid-row-start:3}.timeline-vertical .timeline-start{align-self:center;grid-column-end:2;grid-column-start:1;grid-row-end:4;grid-row-start:1;justify-self:end}.timeline-vertical .timeline-end{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.timeline-vertical:where(.timeline-snap-icon)>li{--timeline-col-start:minmax(0,1fr);--timeline-row-start:0.5rem}.timeline-horizontal .timeline-start{align-self:flex-end;grid-column-end:4;grid-column-start:1;grid-row-end:2;grid-row-start:1;justify-self:center}.timeline-horizontal .timeline-end{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center}.timeline-horizontal:where(.timeline-snap-icon)>li,:where(.timeline-snap-icon)>li{--timeline-col-start:0.5rem;--timeline-row-start:minmax(0,1fr)}:where(.toast){bottom:0;inset-inline-end:0;inset-inline-start:auto;top:auto;--tw-translate-x:0px;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-start){inset-inline-end:auto;inset-inline-start:0;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-center){inset-inline-end:50%;inset-inline-start:50%;--tw-translate-x:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .toast:where(.toast-center)){--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-end){inset-inline-end:0;inset-inline-start:auto;--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-bottom){bottom:0;top:auto;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-middle){bottom:auto;top:50%;--tw-translate-y:-50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.toast:where(.toast-top){bottom:auto;top:0;--tw-translate-y:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}[type=checkbox].toggle-sm{--handleoffset:0.75rem;height:1.25rem;width:2rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{content:var(--tw-content);pointer-events:none;position:absolute;z-index:1;--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{bottom:var(--tooltip-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:before{bottom:auto;left:50%;right:auto;top:var(--tooltip-offset);transform:translateX(-50%)}.avatar.online:before{background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity)))}.avatar.offline:before,.avatar.online:before{border-radius:9999px;content:"";display:block;position:absolute;z-index:10;--tw-bg-opacity:1;height:15%;outline-color:var(--fallback-b1,oklch(var(--b1)/1));outline-style:solid;outline-width:2px;right:7%;top:7%;width:15%}.avatar.offline:before{background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.card-compact .card-body{font-size:.875rem;line-height:1.25rem;padding:1rem}.card-compact .card-title{margin-bottom:.25rem}.card-normal .card-body{font-size:1rem;line-height:1.5rem;padding:var(--padding-card,2rem)}.card-normal .card-title{margin-bottom:.75rem}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-horizontal>:where(:not(:first-child)){margin-bottom:0;margin-top:0;margin-inline-start:-1px}.menu-horizontal>li:not(.menu-title)>details>ul{margin-inline-start:0;margin-top:1rem;padding-bottom:.5rem;padding-inline-end:.5rem;padding-top:.5rem}.menu-horizontal>li>details>ul:before{content:none}:where(.menu-horizontal>li:not(.menu-title)>details>ul){border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);font-size:.875rem;line-height:1.25rem;padding:.25rem .75rem}.menu-sm .menu-title{padding:.5rem .75rem}.modal-top :where(.modal-box){max-width:none;width:100%;--tw-translate-y:-2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:0;border-top-right-radius:0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-middle :where(.modal-box){max-width:32rem;width:91.666667%;--tw-translate-y:0px;--tw-scale-x:.9;--tw-scale-y:.9;border-bottom-left-radius:var(--rounded-box,1rem);border-bottom-right-radius:var(--rounded-box,1rem);border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.modal-bottom :where(.modal-box){max-width:none;width:100%;--tw-translate-y:2.5rem;--tw-scale-x:1;--tw-scale-y:1;border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-box,1rem);border-top-right-radius:var(--rounded-box,1rem);transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.stats-vertical>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(1px*(1 - var(--tw-divide-y-reverse))) calc(0px*var(--tw-divide-x-reverse)) calc(1px*var(--tw-divide-y-reverse)) calc(0px*(1 - var(--tw-divide-x-reverse)))}.stats-vertical{overflow-y:auto}.steps-horizontal .step{grid-template-columns:auto;grid-template-rows:40px 1fr;min-width:4rem}.steps-horizontal .step:before{height:.5rem;width:100%;--tw-translate-x:0px;--tw-translate-y:0px;content:"";margin-inline-start:-100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-horizontal .step):before{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.steps-vertical .step{gap:.5rem;grid-template-columns:40px 1fr;grid-template-rows:auto;justify-items:start;min-height:4rem}.steps-vertical .step:before{height:100%;width:.5rem;--tw-translate-x:-50%;--tw-translate-y:-50%;margin-inline-start:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:is([dir=rtl] .steps-vertical .step):before{--tw-translate-x:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.timeline-vertical>li>hr{width:.25rem}:where(.timeline-vertical:has(.timeline-middle)>li>hr):first-child{border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-vertical:has(.timeline-middle)>li>hr):last-child{border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :first-child>hr:last-child){border-bottom-left-radius:0;border-bottom-right-radius:0;border-top-left-radius:var(--rounded-badge,1.9rem);border-top-right-radius:var(--rounded-badge,1.9rem)}:where(.timeline-vertical:not(:has(.timeline-middle)) :last-child>hr:first-child){border-bottom-left-radius:var(--rounded-badge,1.9rem);border-bottom-right-radius:var(--rounded-badge,1.9rem);border-top-left-radius:0;border-top-right-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):first-child{border-end-end-radius:var(--rounded-badge,1.9rem);border-end-start-radius:0;border-start-end-radius:var(--rounded-badge,1.9rem);border-start-start-radius:0}:where(.timeline-horizontal:has(.timeline-middle)>li>hr):last-child{border-end-end-radius:0;border-end-start-radius:var(--rounded-badge,1.9rem);border-start-end-radius:0;border-start-start-radius:var(--rounded-badge,1.9rem)}.tooltip{display:inline-block;position:relative;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-delay:.1s;transition-duration:.2s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{border-style:solid;border-width:var(--tooltip-tail,0);content:"";display:block;height:0;position:absolute;width:0}.tooltip:before{background-color:var(--tooltip-color);border-radius:.25rem;color:var(--tooltip-text-color);font-size:.875rem;line-height:1.25rem;max-width:20rem;padding:.25rem .5rem;width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{opacity:0;visibility:hidden}.tooltip-top:after,.tooltip:after{border-color:var(--tooltip-color) transparent transparent transparent;bottom:var(--tooltip-tail-offset);left:50%;right:auto;top:auto;transform:translateX(-50%)}.tooltip-bottom:after{border-color:transparent transparent var(--tooltip-color) transparent;bottom:auto;left:50%;right:auto;top:var(--tooltip-tail-offset);transform:translateX(-50%)}.fade-out{opacity:0;transition:opacity .15s ease-in-out}.visible{visibility:visible}.invisible{visibility:hidden}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.left-2{left:.5rem}.right-0{right:0}.right-2{right:.5rem}.right-5{right:1.25rem}.top-0{top:0}.top-2{top:.5rem}.top-5{top:1.25rem}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.z-\[1\]{z-index:1}.z-\[5000\]{z-index:5000}.z-\[6000\]{z-index:6000}.col-span-2{grid-column:span 2/span 2}.m-0{margin:0}.m-2{margin:.5rem}.m-5{margin:1.25rem}.m-auto{margin:auto}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-5{margin-left:1.25rem;margin-right:1.25rem}.mx-auto{margin-left:auto;margin-right:auto}.my-10{margin-bottom:2.5rem;margin-top:2.5rem}.my-2{margin-bottom:.5rem;margin-top:.5rem}.my-3{margin-bottom:.75rem;margin-top:.75rem}.my-4{margin-bottom:1rem;margin-top:1rem}.my-5{margin-bottom:1.25rem;margin-top:1.25rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-10{height:2.5rem}.h-12{height:3rem}.h-2{height:.5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-4{height:1rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-96{height:24rem}.h-\[250px\]{height:250px}.h-\[25rem\]{height:25rem}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.max-h-48{max-height:12rem}.max-h-96{max-height:24rem}.min-h-80{min-height:20rem}.min-h-screen{min-height:100vh}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-2{width:.5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-4{width:1rem}.w-4\/12{width:33.333333%}.w-5{width:1.25rem}.w-52{width:13rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-80{width:20rem}.w-96{width:24rem}.w-auto{width:auto}.w-full{width:100%}.min-w-52{min-width:13rem}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0,.shrink-0{flex-shrink:0}.flex-grow{flex-grow:1}.translate-x-full{--tw-translate-x:100%}.transform,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}.animate-bounce{animation:bounce 1s infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize{resize:both}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.place-items-center{place-items:center}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.75rem*var(--tw-space-y-reverse));margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(2rem*var(--tw-space-y-reverse));margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-base-300>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-divide-opacity,1)))}.justify-self-end{justify-self:end}.justify-self-center{justify-self:center}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.rounded-t-none{border-top-left-radius:0;border-top-right-radius:0}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-base-300{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity,1)))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-error{--tw-border-opacity:1;border-color:var(--fallback-er,oklch(var(--er)/var(--tw-border-opacity,1)))}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-info\/20{border-color:var(--fallback-in,oklch(var(--in)/.2))}.border-neutral{--tw-border-opacity:1;border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity,1)))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-secondary\/20{border-color:var(--fallback-s,oklch(var(--s)/.2))}.border-sky-500{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-success\/20{border-color:var(--fallback-su,oklch(var(--su)/.2))}.border-transparent{border-color:transparent}.border-warning\/20{border-color:var(--fallback-wa,oklch(var(--wa)/.2))}.border-white{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.border-opacity-20{--tw-border-opacity:0.2}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.bg-base-200{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.bg-base-300{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-info{--tw-bg-opacity:1;background-color:var(--fallback-in,oklch(var(--in)/var(--tw-bg-opacity,1)))}.bg-info\/10{background-color:var(--fallback-in,oklch(var(--in)/.1))}.bg-neutral{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity,1)))}.bg-primary{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity,1)))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-secondary{--tw-bg-opacity:1;background-color:var(--fallback-s,oklch(var(--s)/var(--tw-bg-opacity,1)))}.bg-secondary-content{--tw-bg-opacity:1;background-color:var(--fallback-sc,oklch(var(--sc)/var(--tw-bg-opacity,1)))}.bg-secondary\/10{background-color:var(--fallback-s,oklch(var(--s)/.1))}.bg-success{--tw-bg-opacity:1;background-color:var(--fallback-su,oklch(var(--su)/var(--tw-bg-opacity,1)))}.bg-success\/10{background-color:var(--fallback-su,oklch(var(--su)/.1))}.bg-warning{--tw-bg-opacity:1;background-color:var(--fallback-wa,oklch(var(--wa)/var(--tw-bg-opacity,1)))}.bg-warning\/10{background-color:var(--fallback-wa,oklch(var(--wa)/.1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-opacity-10{--tw-bg-opacity:0.1}.bg-opacity-60{--tw-bg-opacity:0.6}.bg-opacity-80{--tw-bg-opacity:0.8}.bg-gradient-to-bl{background-image:linear-gradient(to bottom left,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-base-100{--tw-gradient-from:var(--fallback-b1,oklch(var(--b1)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-b1,oklch(var(--b1)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-500{--tw-gradient-from:#3b82f6 var(--tw-gradient-from-position);--tw-gradient-to:rgba(59,130,246,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-blue-600{--tw-gradient-from:#2563eb var(--tw-gradient-from-position);--tw-gradient-to:rgba(37,99,235,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-400{--tw-gradient-from:#4ade80 var(--tw-gradient-from-position);--tw-gradient-to:rgba(74,222,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-green-500{--tw-gradient-from:#22c55e var(--tw-gradient-from-position);--tw-gradient-to:rgba(34,197,94,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-400{--tw-gradient-from:#fb923c var(--tw-gradient-from-position);--tw-gradient-to:rgba(251,146,60,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-orange-600{--tw-gradient-from:#ea580c var(--tw-gradient-from-position);--tw-gradient-to:rgba(234,88,12,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-primary{--tw-gradient-from:var(--fallback-p,oklch(var(--p)/1)) var(--tw-gradient-from-position);--tw-gradient-to:var(--fallback-p,oklch(var(--p)/0)) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-400{--tw-gradient-from:#f87171 var(--tw-gradient-from-position);--tw-gradient-to:hsla(0,91%,71%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-red-800{--tw-gradient-from:#991b1b var(--tw-gradient-from-position);--tw-gradient-to:rgba(153,27,27,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-400{--tw-gradient-from:#facc15 var(--tw-gradient-from-position);--tw-gradient-to:rgba(250,204,21,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-yellow-700{--tw-gradient-from:#a16207 var(--tw-gradient-from-position);--tw-gradient-to:rgba(161,98,7,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-base-200{--tw-gradient-to:var(--fallback-b2,oklch(var(--b2)/1)) var(--tw-gradient-to-position)}.to-blue-700{--tw-gradient-to:#1d4ed8 var(--tw-gradient-to-position)}.to-blue-800{--tw-gradient-to:#1e40af var(--tw-gradient-to-position)}.to-green-700{--tw-gradient-to:#15803d var(--tw-gradient-to-position)}.to-orange-600{--tw-gradient-to:#ea580c var(--tw-gradient-to-position)}.to-orange-700{--tw-gradient-to:#c2410c var(--tw-gradient-to-position)}.to-purple-600{--tw-gradient-to:#9333ea var(--tw-gradient-to-position)}.to-red-400{--tw-gradient-to:#f87171 var(--tw-gradient-to-position)}.to-red-600{--tw-gradient-to:#dc2626 var(--tw-gradient-to-position)}.to-red-900{--tw-gradient-to:#7f1d1d var(--tw-gradient-to-position)}.to-secondary{--tw-gradient-to:var(--fallback-s,oklch(var(--s)/1)) var(--tw-gradient-to-position)}.to-yellow-400{--tw-gradient-to:#facc15 var(--tw-gradient-to-position)}.to-yellow-600{--tw-gradient-to:#ca8a04 var(--tw-gradient-to-position)}.stroke-current{stroke:currentColor}.stroke-info{stroke:var(--fallback-in,oklch(var(--in)/1))}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-20{padding-bottom:5rem;padding-top:5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pb-2{padding-bottom:.5rem}.pl-4{padding-left:1rem}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pr-10{padding-right:2.5rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.normal-case{text-transform:none}.italic{font-style:italic}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity,1)))}.text-accent-content{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.text-base-content{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity,1)))}.text-base-content\/60{color:var(--fallback-bc,oklch(var(--bc)/.6))}.text-base-content\/70{color:var(--fallback-bc,oklch(var(--bc)/.7))}.text-base-content\/80{color:var(--fallback-bc,oklch(var(--bc)/.8))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity,1)))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity,1)))}.text-info-content{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity,1)))}.text-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity,1)))}.text-neutral-content{--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity,1)))}.text-orange-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.text-primary-content{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity,1)))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity,1)))}.text-secondary-content{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity,1)))}.text-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity,1)))}.text-success-content{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity,1)))}.text-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity,1)))}.text-warning-content{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity,1)))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.decoration-dotted{text-decoration-style:dotted}.placeholder-base-content\/70::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.placeholder-base-content\/70::placeholder{color:var(--fallback-bc,oklch(var(--bc)/.7))}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-inner{--tw-shadow:inset 0 2px 4px 0 rgba(0,0,0,.05);--tw-shadow-colored:inset 0 2px 4px 0 var(--tw-shadow-color)}.shadow-inner,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.blur{--tw-blur:blur(8px)}.blur,.grayscale{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.grayscale{--tw-grayscale:grayscale(100%)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-opacity{transition-duration:.15s;transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-shadow{transition-duration:.15s;transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@tailwind daisyui;.leaflet-right-panel{background:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);margin-right:10px;margin-top:80px;transform:none;transition:right .3s ease-in-out;z-index:400}.add-visit-marker{align-items:center;animation:pulse-visit 2s infinite;background:#fff;border:2px solid #007bff;border-radius:50%;box-shadow:0 2px 8px rgba(0,123,255,.3);display:flex!important;font-size:20px;justify-content:center}@keyframes pulse-visit{0%{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}50%{box-shadow:0 4px 12px rgba(0,123,255,.5);transform:scale(1.1)}to{box-shadow:0 2px 8px rgba(0,123,255,.3);transform:scale(1)}}.visit-form-popup .leaflet-popup-content-wrapper{border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.15)}.leaflet-right-panel.controls-shifted{right:310px}.leaflet-drawer{background:hsla(0,0%,100%,.5);box-shadow:-2px 0 5px rgba(0,0,0,.1);height:100%;position:absolute;right:0;top:0;transform:translateX(100%);transition:transform .3s ease-in-out;width:338px;z-index:450}.leaflet-drawer.open{transform:translateX(0)}.leaflet-control-button,.leaflet-control-layers,.toggle-panel-button{transition:right .3s ease-in-out;z-index:500}.controls-shifted{right:338px!important}.leaflet-control-custom{align-items:center;background-color:#fff;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.3);cursor:pointer;display:flex;height:30px;justify-content:center;width:30px}.leaflet-control-custom:hover{background-color:#f3f4f6}#selection-tool-button.active{background-color:#60a5fa;color:#fff}#cancel-selection-button{margin-bottom:1rem;width:100%}@media (hover:hover){.hover\:btn-ghost:hover:hover{border-color:transparent}@supports (color:oklch(0 0 0)){.hover\:btn-ghost:hover:hover{background-color:var(--fallback-bc,oklch(var(--bc)/.2))}}.hover\:btn-info:hover.btn-outline:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}}@supports not (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--fallback-in)}}@supports (color:color-mix(in oklab,black,black)){.hover\:btn-info:hover.btn-outline.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}@supports (color:oklch(0 0 0)){.hover\:btn-info:hover{--btn-color:var(--in)}}.hover\:btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)));outline-color:var(--fallback-in,oklch(var(--in)/1))}.hover\:btn-ghost:hover{background-color:transparent;border-color:transparent;border-width:1px;color:currentColor;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.hover\:btn-ghost:hover.btn-active{background-color:var(--fallback-bc,oklch(var(--bc)/.2));border-color:transparent}.hover\:btn-info:hover.btn-outline{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.hover\:btn-info:hover.btn-outline.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.hover\:input-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)))}.hover\:input-primary:hover:focus,.hover\:input-primary:hover:focus-within{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));outline-color:var(--fallback-p,oklch(var(--p)/1))}.focus\:input-ghost:focus{--tw-bg-opacity:0.05}.focus\:input-ghost:focus:focus,.focus\:input-ghost:focus:focus-within{--tw-bg-opacity:1;--tw-text-opacity:1;box-shadow:none;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}@media not all and (min-width:768px){.max-md\:timeline-compact,.max-md\:timeline-compact +.timeline-horizontal{--timeline-row-start:0}.max-md\:timeline-compact .timeline-horizontal .timeline-start,.max-md\:timeline-compact .timeline-start{align-self:flex-start;grid-column-end:4;grid-column-start:1;grid-row-end:4;grid-row-start:3;justify-self:center;margin:.25rem}.max-md\:timeline-compact .timeline-horizontal li:has(.timeline-start) .timeline-end,.max-md\:timeline-compact li:has(.timeline-start) .timeline-end{grid-column-start:none;grid-row-start:auto}.max-md\:timeline-compact.timeline-vertical>li{--timeline-col-start:0}.max-md\:timeline-compact.timeline-vertical .timeline-start{align-self:center;grid-column-end:4;grid-column-start:3;grid-row-end:4;grid-row-start:1;justify-self:start}.max-md\:timeline-compact.timeline-vertical li:has(.timeline-start) .timeline-end{grid-column-start:auto;grid-row-start:none}}@media (min-width:1024px){.lg\:stats-horizontal{grid-auto-flow:column}.lg\:stats-horizontal>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;--tw-divide-y-reverse:0;border-width:calc(0px*(1 - var(--tw-divide-y-reverse))) calc(1px*var(--tw-divide-x-reverse)) calc(0px*var(--tw-divide-y-reverse)) calc(1px*(1 - var(--tw-divide-x-reverse)))}.lg\:stats-horizontal{overflow-x:auto}:is([dir=rtl] .lg\:stats-horizontal){--tw-divide-x-reverse:1}}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05}.hover\:scale-105:hover,.hover\:scale-\[1\.02\]:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:cursor-pointer:hover{cursor:pointer}.hover\:border-primary:hover{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.hover\:border-primary\/40:hover{border-color:var(--fallback-p,oklch(var(--p)/.4))}.hover\:bg-accent:hover{--tw-bg-opacity:1;background-color:var(--fallback-a,oklch(var(--a)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200:hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity,1)))}.hover\:bg-base-200\/50:hover{background-color:var(--fallback-b2,oklch(var(--b2)/.5))}.hover\:bg-base-300:hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity,1)))}.hover\:bg-blue-50:hover{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:text-accent-content:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity,1)))}.hover\:text-blue-800:hover{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-primary:hover{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.hover\:shadow-2xl:hover{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.hover\:shadow-2xl:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-primary\/20:hover{--tw-shadow-color:var(--fallback-p,oklch(var(--p)/0.2));--tw-shadow:var(--tw-shadow-colored)}.focus\:border-primary:focus{--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity,1)))}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-base-100:focus{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity,1)))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.group:hover .group-hover\:text-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity,1)))}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width:640px){.sm\:inline{display:inline}.sm\:w-1\/12{width:8.333333%}.sm\:w-2\/12{width:16.666667%}.sm\:w-6\/12{width:50%}.sm\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.sm\: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\:h-64{height:16rem}.md\:min-h-64{min-height:16rem}.md\:w-1\/12{width:8.333333%}.md\:w-2\/12{width:16.666667%}.md\:w-2\/3{width:66.666667%}.md\:w-3\/12{width:25%}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.md\:text-end{text-align:end}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:w-1\/12{width:8.333333%}.lg\:w-1\/2{width:50%}.lg\:w-2\/12{width:16.666667%}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse))}.lg\:text-left{text-align:left}}@media (min-width:1280px){.xl\:inline{display:inline}.xl\:hidden{display:none}} \ No newline at end of file diff --git a/app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg b/app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg new file mode 100755 index 00000000..072b425a --- /dev/null +++ b/app/assets/images/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/assets/images/backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg b/app/assets/images/backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg new file mode 100644 index 00000000..1af6ba45 Binary files /dev/null and b/app/assets/images/backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg b/app/assets/images/backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg new file mode 100644 index 00000000..f77795a8 Binary files /dev/null and b/app/assets/images/backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg b/app/assets/images/backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg new file mode 100644 index 00000000..6ca91bf1 Binary files /dev/null and b/app/assets/images/backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg b/app/assets/images/backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg new file mode 100644 index 00000000..3da2dd7b Binary files /dev/null and b/app/assets/images/backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg b/app/assets/images/backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg new file mode 100644 index 00000000..341af9dc Binary files /dev/null and b/app/assets/images/backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg b/app/assets/images/backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg new file mode 100644 index 00000000..f6d07ed9 Binary files /dev/null and b/app/assets/images/backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg b/app/assets/images/backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg new file mode 100644 index 00000000..21304dcb Binary files /dev/null and b/app/assets/images/backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg b/app/assets/images/backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg new file mode 100644 index 00000000..41bde191 Binary files /dev/null and b/app/assets/images/backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg b/app/assets/images/backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg new file mode 100644 index 00000000..bb2d8264 Binary files /dev/null and b/app/assets/images/backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg b/app/assets/images/backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg new file mode 100644 index 00000000..01c5dd0d Binary files /dev/null and b/app/assets/images/backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg b/app/assets/images/backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg new file mode 100644 index 00000000..83176a59 Binary files /dev/null and b/app/assets/images/backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg b/app/assets/images/backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg new file mode 100644 index 00000000..3ca271d8 Binary files /dev/null and b/app/assets/images/backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg differ diff --git a/app/assets/images/backgrounds/months/pascal-debrunner-dppGOhcPBW0-unsplash.jpg b/app/assets/images/backgrounds/months/pascal-debrunner-dppGOhcPBW0-unsplash.jpg new file mode 100644 index 00000000..693a7cb9 Binary files /dev/null and b/app/assets/images/backgrounds/months/pascal-debrunner-dppGOhcPBW0-unsplash.jpg differ diff --git a/app/assets/images/favicon/site.webmanifest.erb b/app/assets/images/favicon/site.webmanifest.erb deleted file mode 100644 index 9cff8bac..00000000 --- a/app/assets/images/favicon/site.webmanifest.erb +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "Dawarich", - "short_name": "Dawarich", - "icons": [ - { - "src": "<%= asset_path 'favicon/android-chrome-192x192.png' %>", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "<%= asset_path 'favicon/android-chrome-512x512.png' %>", - "sizes": "512x512", - "type": "image/png" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index bd822bce..52e272ff 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -92,7 +92,7 @@ } .loading-spinner::before { - content: '🔵'; + content: ''; font-size: 18px; animation: spinner 1s linear infinite; } @@ -101,3 +101,63 @@ content: '✅'; animation: none; } + +/* Flash message animations */ +@keyframes slideInFromRight { + 0% { + transform: translateX(100%); + opacity: 0; + } + 100% { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOutToRight { + 0% { + transform: translateX(0); + opacity: 1; + } + 100% { + transform: translateX(100%); + opacity: 0; + } +} + +/* Family feature specific styles */ +.family-member-card { + transition: all 0.2s ease-in-out; +} + +.family-member-card:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.invitation-card { + border-left: 4px solid #f59e0b; +} + +.family-invitation-form { + max-width: 500px; +} + +/* Loading states */ +.btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.loading-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 257a1910..4438d527 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -13,6 +13,7 @@ */ @import 'actiontext.css'; +@import 'leaflet_theme.css'; @layer components { .fade-out { @@ -33,18 +34,44 @@ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); } +/* Add Visit Marker Styles */ +.add-visit-marker { + display: flex !important; + align-items: center; + justify-content: center; + font-size: 20px; + background: white; + border: 2px solid #007bff; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + animation: pulse-visit 2s infinite; +} + +@keyframes pulse-visit { + 0% { + transform: scale(1); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + } + 50% { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5); + } + 100% { + transform: scale(1); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + } +} + +/* Visit Form Popup Styles */ +.visit-form-popup .leaflet-popup-content-wrapper { + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + .leaflet-right-panel.controls-shifted { right: 310px; } -.leaflet-control-button { - background-color: white !important; - color: #374151 !important; -} - -.leaflet-control-button:hover { - background-color: #f3f4f6 !important; -} /* Drawer Panel Styles */ .leaflet-drawer { diff --git a/app/assets/stylesheets/leaflet_theme.css b/app/assets/stylesheets/leaflet_theme.css new file mode 100644 index 00000000..09c77b51 --- /dev/null +++ b/app/assets/stylesheets/leaflet_theme.css @@ -0,0 +1,189 @@ +/* Leaflet Theme Styles - Light and Dark mode support */ + +/* CSS Custom Properties for Light Theme */ +[data-theme="light"] { + --leaflet-bg-color: #ffffff; + --leaflet-text-color: #000000; + --leaflet-border-color: #e5e7eb; + --leaflet-shadow-color: rgba(0, 0, 0, 0.1); + --leaflet-hover-color: #f3f4f6; + --leaflet-link-color: #0066cc; + --leaflet-scale-bg: rgba(255, 255, 255, 0.9); +} + +/* CSS Custom Properties for Dark Theme */ +[data-theme="dark"] { + --leaflet-bg-color: #374151; + --leaflet-text-color: #ffffff; + --leaflet-border-color: #4b5563; + --leaflet-shadow-color: rgba(0, 0, 0, 0.3); + --leaflet-hover-color: #4b5563; + --leaflet-link-color: #66b3ff; + --leaflet-scale-bg: rgba(55, 65, 81, 0.9); +} + +/* Leaflet default controls theme override */ +.leaflet-control-layers, +.leaflet-control-zoom, +.leaflet-control-attribution, +.leaflet-bar a, +.leaflet-control-layers-toggle, +.leaflet-control-layers-list, +.leaflet-control-draw { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; + border-color: var(--leaflet-border-color) !important; + box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important; +} + +/* Leaflet zoom buttons */ +.leaflet-control-zoom a { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; + border-bottom: 1px solid var(--leaflet-border-color) !important; +} + +.leaflet-control-zoom a:hover { + background-color: var(--leaflet-hover-color) !important; +} + +/* Leaflet layer control */ +.leaflet-control-layers-toggle { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; +} + +.leaflet-control-layers-expanded { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; +} + +.leaflet-control-layers label { + color: var(--leaflet-text-color) !important; +} + +/* Leaflet Draw controls */ +.leaflet-draw-toolbar a { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; + border-bottom: 1px solid var(--leaflet-border-color) !important; +} + +.leaflet-draw-toolbar a:hover { + background-color: var(--leaflet-hover-color) !important; +} + +.leaflet-draw-actions a { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; +} + +/* Leaflet popups */ +.leaflet-popup-content-wrapper { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; +} + +.leaflet-popup-tip { + background-color: var(--leaflet-bg-color) !important; +} + +/* Attribution control */ +.leaflet-control-attribution a { + color: var(--leaflet-link-color) !important; +} + +/* Custom control buttons */ +.leaflet-control-button, +.add-visit-button, +.leaflet-bar button { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; + border: 1px solid var(--leaflet-border-color) !important; + box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important; +} + +.leaflet-control-button:hover, +.add-visit-button:hover, +.leaflet-bar button:hover { + background-color: var(--leaflet-hover-color) !important; +} + +/* Any other custom controls */ +.leaflet-top .leaflet-control button, +.leaflet-bottom .leaflet-control button, +.leaflet-left .leaflet-control button, +.leaflet-right .leaflet-control button { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; + border: 1px solid var(--leaflet-border-color) !important; +} + +/* Location search button */ +.location-search-toggle, +#location-search-toggle { + background-color: var(--leaflet-bg-color) !important; + color: var(--leaflet-text-color) !important; + border: 1px solid var(--leaflet-border-color) !important; + box-shadow: 0 1px 4px var(--leaflet-shadow-color) !important; +} + +.location-search-toggle:hover, +#location-search-toggle:hover { + background-color: var(--leaflet-hover-color) !important; +} + +/* Distance scale control */ +.leaflet-control-scale { + background: var(--leaflet-scale-bg) !important; + border-radius: 3px !important; + padding: 2px !important; +} + +/* Family member tooltip - dark styled like the visit popup */ +.leaflet-tooltip.family-member-tooltip { + background-color: #374151 !important; + color: #ffffff !important; + border: 1px solid #4b5563 !important; + border-radius: 4px !important; + padding: 4px 8px !important; + font-size: 11px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; +} + +.leaflet-tooltip.family-member-tooltip::before { + border-top-color: #374151 !important; +} + +/* Family member popup - just override colors, keep default layout */ +.leaflet-popup-content-wrapper:has(.family-member-popup) { + background-color: #1f2937 !important; + color: #f9fafb !important; +} + +.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip { + background-color: #1f2937 !important; +} + +/* Family member marker pulse animation for recent updates */ +@keyframes family-marker-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); + } + 50% { + box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +.family-member-marker-recent { + animation: family-marker-pulse 2s infinite; + border-radius: 50% !important; +} + +.family-member-marker-recent .leaflet-marker-icon > div { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 0 rgba(16, 185, 129, 0.7); + border-radius: 50%; +} \ No newline at end of file diff --git a/app/assets/svg/icons/lucide/outline/activity.svg b/app/assets/svg/icons/lucide/outline/activity.svg new file mode 100644 index 00000000..629b81c9 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/activity.svg @@ -0,0 +1,13 @@ + + + diff --git a/app/assets/svg/icons/lucide/outline/bell.svg b/app/assets/svg/icons/lucide/outline/bell.svg new file mode 100644 index 00000000..c3a5ae9a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/bell.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/building.svg b/app/assets/svg/icons/lucide/outline/building.svg new file mode 100644 index 00000000..d1a3f59b --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/building.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/bus.svg b/app/assets/svg/icons/lucide/outline/bus.svg new file mode 100644 index 00000000..9fdef2d1 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/bus.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/calendar-check-2.svg b/app/assets/svg/icons/lucide/outline/calendar-check-2.svg new file mode 100644 index 00000000..c23c6d78 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/calendar-check-2.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/camera.svg b/app/assets/svg/icons/lucide/outline/camera.svg new file mode 100644 index 00000000..1e82bf45 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/camera.svg @@ -0,0 +1,14 @@ + + + + diff --git a/app/assets/svg/icons/lucide/outline/car.svg b/app/assets/svg/icons/lucide/outline/car.svg new file mode 100644 index 00000000..c99feda0 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/car.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/chart-column.svg b/app/assets/svg/icons/lucide/outline/chart-column.svg new file mode 100644 index 00000000..bc565a6a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chart-column.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/chevron-left.svg b/app/assets/svg/icons/lucide/outline/chevron-left.svg new file mode 100644 index 00000000..47bdb982 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chevron-left.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/chevron-right.svg b/app/assets/svg/icons/lucide/outline/chevron-right.svg new file mode 100644 index 00000000..4c0ff5ee --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/chevron-right.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/circle-alert.svg b/app/assets/svg/icons/lucide/outline/circle-alert.svg new file mode 100644 index 00000000..bce0713a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/circle-alert.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/circle-check.svg b/app/assets/svg/icons/lucide/outline/circle-check.svg new file mode 100644 index 00000000..cd6c711c --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/circle-check.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/circle-x.svg b/app/assets/svg/icons/lucide/outline/circle-x.svg new file mode 100644 index 00000000..db26d2f3 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/circle-x.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/copy.svg b/app/assets/svg/icons/lucide/outline/copy.svg new file mode 100644 index 00000000..f62ce99c --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/copy.svg @@ -0,0 +1,14 @@ + + + + diff --git a/app/assets/svg/icons/lucide/outline/earth.svg b/app/assets/svg/icons/lucide/outline/earth.svg new file mode 100644 index 00000000..b682a309 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/earth.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/flame.svg b/app/assets/svg/icons/lucide/outline/flame.svg new file mode 100644 index 00000000..0abb88a4 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/flame.svg @@ -0,0 +1,13 @@ + + + diff --git a/app/assets/svg/icons/lucide/outline/flower.svg b/app/assets/svg/icons/lucide/outline/flower.svg new file mode 100644 index 00000000..81265ee8 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/flower.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/globe.svg b/app/assets/svg/icons/lucide/outline/globe.svg new file mode 100644 index 00000000..53c80ee3 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/globe.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/app/assets/svg/icons/lucide/outline/goal.svg b/app/assets/svg/icons/lucide/outline/goal.svg new file mode 100644 index 00000000..84be52d6 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/goal.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/heart.svg b/app/assets/svg/icons/lucide/outline/heart.svg new file mode 100644 index 00000000..fc6135a7 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/heart.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/house.svg b/app/assets/svg/icons/lucide/outline/house.svg new file mode 100644 index 00000000..10e4097e --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/house.svg @@ -0,0 +1,14 @@ + + + + diff --git a/app/assets/svg/icons/lucide/outline/info.svg b/app/assets/svg/icons/lucide/outline/info.svg new file mode 100644 index 00000000..2a46eac2 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/info.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/app/assets/svg/icons/lucide/outline/leaf.svg b/app/assets/svg/icons/lucide/outline/leaf.svg new file mode 100644 index 00000000..af8901e4 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/leaf.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/lightbulb.svg b/app/assets/svg/icons/lucide/outline/lightbulb.svg new file mode 100644 index 00000000..3f4e4091 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/lightbulb.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/app/assets/svg/icons/lucide/outline/link.svg b/app/assets/svg/icons/lucide/outline/link.svg new file mode 100644 index 00000000..645e746a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/link.svg @@ -0,0 +1,14 @@ + + + + diff --git a/app/assets/svg/icons/lucide/outline/map-pin-plus.svg b/app/assets/svg/icons/lucide/outline/map-pin-plus.svg new file mode 100644 index 00000000..794d53cf --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/map-pin-plus.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/map-pin.svg b/app/assets/svg/icons/lucide/outline/map-pin.svg new file mode 100644 index 00000000..5d73fbd4 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/map-pin.svg @@ -0,0 +1,14 @@ + + + + diff --git a/app/assets/svg/icons/lucide/outline/map-plus.svg b/app/assets/svg/icons/lucide/outline/map-plus.svg new file mode 100644 index 00000000..c326bb9d --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/map-plus.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/map.svg b/app/assets/svg/icons/lucide/outline/map.svg new file mode 100644 index 00000000..8a852560 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/map.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/app/assets/svg/icons/lucide/outline/plane.svg b/app/assets/svg/icons/lucide/outline/plane.svg new file mode 100644 index 00000000..6f843bf8 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/plane.svg @@ -0,0 +1,13 @@ + + + diff --git a/app/assets/svg/icons/lucide/outline/refresh-ccw.svg b/app/assets/svg/icons/lucide/outline/refresh-ccw.svg new file mode 100644 index 00000000..9088690e --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/refresh-ccw.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/share.svg b/app/assets/svg/icons/lucide/outline/share.svg new file mode 100644 index 00000000..9165e1ce --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/share.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/app/assets/svg/icons/lucide/outline/shield-check.svg b/app/assets/svg/icons/lucide/outline/shield-check.svg new file mode 100644 index 00000000..26716f2f --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/shield-check.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/shopping-cart.svg b/app/assets/svg/icons/lucide/outline/shopping-cart.svg new file mode 100644 index 00000000..6d9f8844 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/shopping-cart.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/app/assets/svg/icons/lucide/outline/snowflake.svg b/app/assets/svg/icons/lucide/outline/snowflake.svg new file mode 100644 index 00000000..2d3f15f4 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/snowflake.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/square-pen.svg b/app/assets/svg/icons/lucide/outline/square-pen.svg new file mode 100644 index 00000000..06515ddf --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/square-pen.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/star.svg b/app/assets/svg/icons/lucide/outline/star.svg new file mode 100644 index 00000000..b35bedd2 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/star.svg @@ -0,0 +1,13 @@ + + + diff --git a/app/assets/svg/icons/lucide/outline/trash-2.svg b/app/assets/svg/icons/lucide/outline/trash-2.svg new file mode 100644 index 00000000..1a24da1a --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/trash-2.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/tree-palm.svg b/app/assets/svg/icons/lucide/outline/tree-palm.svg new file mode 100644 index 00000000..685a5ea7 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/tree-palm.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/trending-up.svg b/app/assets/svg/icons/lucide/outline/trending-up.svg new file mode 100644 index 00000000..2ff1fb20 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/trending-up.svg @@ -0,0 +1,14 @@ + + + + diff --git a/app/assets/svg/icons/lucide/outline/trophy.svg b/app/assets/svg/icons/lucide/outline/trophy.svg new file mode 100644 index 00000000..f254d0c3 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/trophy.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/app/assets/svg/icons/lucide/outline/user.svg b/app/assets/svg/icons/lucide/outline/user.svg new file mode 100644 index 00000000..9d318028 --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/user.svg @@ -0,0 +1 @@ + diff --git a/app/assets/svg/icons/lucide/outline/users.svg b/app/assets/svg/icons/lucide/outline/users.svg new file mode 100644 index 00000000..e06a26ed --- /dev/null +++ b/app/assets/svg/icons/lucide/outline/users.svg @@ -0,0 +1 @@ + diff --git a/app/channels/family_locations_channel.rb b/app/channels/family_locations_channel.rb new file mode 100644 index 00000000..4520d3af --- /dev/null +++ b/app/channels/family_locations_channel.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class FamilyLocationsChannel < ApplicationCable::Channel + def subscribed + return reject unless family_feature_enabled? + return reject unless current_user.in_family? + + stream_for current_user.family + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + end + + private + + def family_feature_enabled? + DawarichSettings.family_feature_enabled? + end +end diff --git a/app/controllers/api/v1/areas_controller.rb b/app/controllers/api/v1/areas_controller.rb index 4ccebd7c..81e20d17 100644 --- a/app/controllers/api/v1/areas_controller.rb +++ b/app/controllers/api/v1/areas_controller.rb @@ -15,7 +15,7 @@ class Api::V1::AreasController < ApiController if @area.save render json: @area, status: :created else - render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity + render json: { errors: @area.errors.full_messages }, status: :unprocessable_content end end @@ -23,7 +23,7 @@ class Api::V1::AreasController < ApiController if @area.update(area_params) render json: @area, status: :ok else - render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity + render json: { errors: @area.errors.full_messages }, status: :unprocessable_content end end diff --git a/app/controllers/api/v1/countries/visited_cities_controller.rb b/app/controllers/api/v1/countries/visited_cities_controller.rb index 85e53f7d..5af80348 100644 --- a/app/controllers/api/v1/countries/visited_cities_controller.rb +++ b/app/controllers/api/v1/countries/visited_cities_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Countries::VisitedCitiesController < ApiController end_at = DateTime.parse(params[:end_at]).to_i points = current_api_user - .tracked_points + .points .where(timestamp: start_at..end_at) render json: { data: CountriesAndCities.new(points).call } diff --git a/app/controllers/api/v1/families_controller.rb b/app/controllers/api/v1/families_controller.rb new file mode 100644 index 00000000..3cd93894 --- /dev/null +++ b/app/controllers/api/v1/families_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Api::V1::FamiliesController < ApiController + before_action :ensure_family_feature_enabled! + before_action :ensure_user_in_family! + + def locations + family_locations = Families::Locations.new(current_api_user).call + + render json: { + locations: family_locations, + updated_at: Time.current.iso8601, + sharing_enabled: current_api_user.family_sharing_enabled? + } + end + + private + + def ensure_user_in_family! + return if current_api_user.in_family? + + render json: { error: 'User is not part of a family' }, status: :forbidden + end +end diff --git a/app/controllers/api/v1/locations_controller.rb b/app/controllers/api/v1/locations_controller.rb new file mode 100644 index 00000000..1245186c --- /dev/null +++ b/app/controllers/api/v1/locations_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class Api::V1::LocationsController < ApiController + before_action :validate_search_params, only: [:index] + before_action :validate_suggestion_params, only: [:suggestions] + + def index + if coordinate_search? + search_results = LocationSearch::PointFinder.new(current_api_user, search_params).call + + render json: Api::LocationSearchResultSerializer.new(search_results).call + else + render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request + end + rescue StandardError => e + Rails.logger.error "Location search error: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + render json: { error: 'Search failed. Please try again.' }, status: :internal_server_error + end + + def suggestions + if search_query.present? && search_query.length >= 2 + suggestions = LocationSearch::GeocodingService.new(search_query).search + + # Format suggestions for the frontend + formatted_suggestions = suggestions.map do |suggestion| + { + name: suggestion[:name], + address: suggestion[:address], + coordinates: [suggestion[:lat], suggestion[:lon]], + type: suggestion[:type] + } + end + + render json: { suggestions: formatted_suggestions } + else + render json: { suggestions: [] } + end + rescue StandardError => e + Rails.logger.error "Suggestions error: #{e.message}" + render json: { suggestions: [] } + end + + private + + def search_query + params[:q]&.strip + end + + def search_params + { + latitude: params[:lat]&.to_f, + longitude: params[:lon]&.to_f, + limit: params[:limit]&.to_i || 50, + date_from: parse_date(params[:date_from]), + date_to: parse_date(params[:date_to]), + radius_override: params[:radius_override]&.to_i + } + end + + def coordinate_search? + params[:lat].present? && params[:lon].present? + end + + def validate_search_params + unless coordinate_search? + render json: { error: 'Coordinates (lat, lon) are required' }, status: :bad_request + return false + end + + lat = params[:lat]&.to_f + lon = params[:lon]&.to_f + + if lat.abs > 90 || lon.abs > 180 + render json: { error: 'Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180' }, + status: :bad_request + return false + end + + true + end + + def validate_suggestion_params + if search_query.present? && search_query.length > 200 + render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request + return false + end + + true + end + + def parse_date(date_string) + return nil if date_string.blank? + + Date.parse(date_string) + rescue ArgumentError + nil + end +end diff --git a/app/controllers/api/v1/maps/hexagons_controller.rb b/app/controllers/api/v1/maps/hexagons_controller.rb new file mode 100644 index 00000000..900d3fd3 --- /dev/null +++ b/app/controllers/api/v1/maps/hexagons_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Api::V1::Maps::HexagonsController < ApiController + skip_before_action :authenticate_api_key, if: :public_sharing_request? + + def index + context = resolve_hexagon_context + + result = Maps::HexagonRequestHandler.new( + params: params, + user: context[:user] || current_api_user, + stat: context[:stat], + start_date: context[:start_date], + end_date: context[:end_date] + ).call + + render json: result + rescue ActionController::ParameterMissing => e + render json: { error: "Missing required parameter: #{e.param}" }, status: :bad_request + rescue ActionController::BadRequest => e + render json: { error: e.message }, status: :bad_request + rescue ActiveRecord::RecordNotFound => e + render json: { error: 'Shared stats not found or no longer available' }, status: :not_found + rescue Stats::CalculateMonth::PostGISError => e + render json: { error: e.message }, status: :bad_request + rescue StandardError => _e + handle_service_error + end + + def bounds + context = resolve_hexagon_context + + result = Maps::BoundsCalculator.new( + user: context[:user] || context[:target_user], + start_date: context[:start_date], + end_date: context[:end_date] + ).call + + if result[:success] + render json: result[:data] + else + render json: { + error: result[:error], + point_count: result[:point_count] + }, status: :not_found + end + rescue ActiveRecord::RecordNotFound => e + render json: { error: 'Shared stats not found or no longer available' }, status: :not_found + rescue ArgumentError => e + render json: { error: e.message }, status: :bad_request + rescue Maps::BoundsCalculator::NoUserFoundError => e + render json: { error: e.message }, status: :not_found + rescue Maps::BoundsCalculator::NoDateRangeError => e + render json: { error: e.message }, status: :bad_request + end + + private + + def resolve_hexagon_context + return resolve_public_sharing_context if public_sharing_request? + + resolve_authenticated_context + end + + def resolve_public_sharing_context + stat = Stat.find_by(sharing_uuid: params[:uuid]) + raise ActiveRecord::RecordNotFound unless stat&.public_accessible? + + { + user: stat.user, + start_date: Date.new(stat.year, stat.month, 1).beginning_of_day.iso8601, + end_date: Date.new(stat.year, stat.month, 1).end_of_month.end_of_day.iso8601, + stat: stat + } + end + + def resolve_authenticated_context + { + user: current_api_user, + start_date: params[:start_date], + end_date: params[:end_date], + stat: nil + } + end + + def handle_service_error + render json: { error: 'Failed to generate hexagon grid' }, status: :internal_server_error + end + + def public_sharing_request? + params[:uuid].present? + end +end diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index 505bb123..6dd2cf93 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -10,7 +10,7 @@ class Api::V1::PointsController < ApiController order = params[:order] || 'desc' points = current_api_user - .tracked_points + .points .where(timestamp: start_at..end_at) .order(timestamp: order) .page(params[:page]) @@ -31,7 +31,7 @@ class Api::V1::PointsController < ApiController end def update - point = current_api_user.tracked_points.find(params[:id]) + point = current_api_user.points.find(params[:id]) point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})") @@ -39,7 +39,7 @@ class Api::V1::PointsController < ApiController end def destroy - point = current_api_user.tracked_points.find(params[:id]) + point = current_api_user.points.find(params[:id]) point.destroy render json: { message: 'Point deleted successfully' } diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index 10620730..7404ec01 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -18,7 +18,7 @@ class Api::V1::SettingsController < ApiController status: :ok else render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages }, - status: :unprocessable_entity + status: :unprocessable_content end end diff --git a/app/controllers/api/v1/subscriptions_controller.rb b/app/controllers/api/v1/subscriptions_controller.rb index 2da2e97d..cc510d67 100644 --- a/app/controllers/api/v1/subscriptions_controller.rb +++ b/app/controllers/api/v1/subscriptions_controller.rb @@ -15,6 +15,6 @@ class Api::V1::SubscriptionsController < ApiController render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized rescue ArgumentError => e ExceptionReporter.call(e) - render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity + render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_content end end diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 9832d6b4..4ec4173b 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -10,6 +10,19 @@ class Api::V1::VisitsController < ApiController render json: serialized_visits end + def create + service = Visits::Create.new(current_api_user, visit_params) + + result = service.call + + if result + render json: Api::VisitSerializer.new(service.visit).call + else + error_message = service.errors || 'Failed to create visit' + render json: { error: error_message }, status: :unprocessable_content + end + end + def update visit = current_api_user.visits.find(params[:id]) visit = update_visit(visit) @@ -21,7 +34,7 @@ class Api::V1::VisitsController < ApiController # Validate that we have at least 2 visit IDs visit_ids = params[:visit_ids] if visit_ids.blank? || visit_ids.length < 2 - return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_entity + return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_content end # Find all visits that belong to the current user @@ -39,7 +52,7 @@ class Api::V1::VisitsController < ApiController if merged_visit&.persisted? render json: Api::VisitSerializer.new(merged_visit).call, status: :ok else - render json: { error: service.errors.join(', ') }, status: :unprocessable_entity + render json: { error: service.errors.join(', ') }, status: :unprocessable_content end end @@ -58,14 +71,29 @@ class Api::V1::VisitsController < ApiController updated_count: result[:count] }, status: :ok else - render json: { error: service.errors.join(', ') }, status: :unprocessable_entity + render json: { error: service.errors.join(', ') }, status: :unprocessable_content end end + def destroy + visit = current_api_user.visits.find(params[:id]) + + if visit.destroy + head :no_content + else + render json: { + error: 'Failed to delete visit', + errors: visit.errors.full_messages + }, status: :unprocessable_content + end + rescue ActiveRecord::RecordNotFound + render json: { error: 'Visit not found' }, status: :not_found + end + private def visit_params - params.require(:visit).permit(:name, :place_id, :status) + params.require(:visit).permit(:name, :place_id, :status, :latitude, :longitude, :started_at, :ended_at) end def merge_params @@ -78,6 +106,8 @@ class Api::V1::VisitsController < ApiController def update_visit(visit) visit_params.each do |key, value| + next if %w[latitude longitude].include?(key.to_s) + visit[key] = value visit.name = visit.place.name if visit_params[:place_id].present? end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 500b9711..515aebef 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,9 @@ class ApplicationController < ActionController::Base include Pundit::Authorization - before_action :unread_notifications, :set_self_hosted_status + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + + before_action :unread_notifications, :set_self_hosted_status, :store_client_header protected @@ -16,13 +18,13 @@ class ApplicationController < ActionController::Base def authenticate_admin! return if current_user&.admin? - redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other + user_not_authorized end def authenticate_self_hosted! return if DawarichSettings.self_hosted? - redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other + user_not_authorized end def authenticate_active_user! @@ -34,7 +36,30 @@ class ApplicationController < ActionController::Base def authenticate_non_self_hosted! return unless DawarichSettings.self_hosted? - redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other + user_not_authorized + end + + def after_sign_in_path_for(resource) + client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client] + + case client_type + when 'ios' + payload = { api_key: resource.api_key, exp: 5.minutes.from_now.to_i } + + token = Subscription::EncodeJwtToken.new( + payload, ENV['AUTH_JWT_SECRET_KEY'] + ).call + + ios_success_path(token:) + else + super + end + end + + def ensure_family_feature_enabled! + return if DawarichSettings.family_feature_enabled? + + render json: { error: 'Family feature is not enabled' }, status: :forbidden end private @@ -42,4 +67,16 @@ class ApplicationController < ActionController::Base def set_self_hosted_status @self_hosted = DawarichSettings.self_hosted? end + + def store_client_header + return unless request.headers['X-Dawarich-Client'] + + session[:dawarich_client] = request.headers['X-Dawarich-Client'] + end + + def user_not_authorized + redirect_to (request.referer || root_path), + alert: 'You are not authorized to perform this action.', + status: :see_other + end end diff --git a/app/controllers/auth/ios_controller.rb b/app/controllers/auth/ios_controller.rb new file mode 100644 index 00000000..d03a0e2f --- /dev/null +++ b/app/controllers/auth/ios_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Auth + class IosController < ApplicationController + def success + # If token is provided, this is the final callback for ASWebAuthenticationSession + if params[:token].present? + # ASWebAuthenticationSession will capture this URL and extract the token + render plain: "Authentication successful! You can close this window.", status: :ok + else + # This should not happen with our current flow, but keeping for safety + render json: { + success: true, + message: 'iOS authentication successful', + redirect_url: root_url + }, status: :ok + end + end + + end +end \ No newline at end of file diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb index efd2d502..0c59e1bf 100644 --- a/app/controllers/exports_controller.rb +++ b/app/controllers/exports_controller.rb @@ -27,7 +27,7 @@ class ExportsController < ApplicationController ExceptionReporter.call(e) - redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_entity + redirect_to exports_url, alert: "Export failed to initiate: #{e.message}", status: :unprocessable_content end def destroy diff --git a/app/controllers/families_controller.rb b/app/controllers/families_controller.rb new file mode 100644 index 00000000..5ce52f56 --- /dev/null +++ b/app/controllers/families_controller.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class FamiliesController < ApplicationController + before_action :authenticate_user! + before_action :ensure_family_feature_enabled! + before_action :set_family, only: %i[show edit update destroy update_location_sharing] + + def show + authorize @family + + @members = @family.members.includes(:family_membership).order(:email) + @pending_invitations = @family.active_invitations.order(:created_at) + + @member_count = @family.member_count + @can_invite = @family.can_add_members? + end + + def new + redirect_to family_path and return if current_user.in_family? + + @family = Family.new + authorize @family + end + + def create + @family = Family.new(family_params) + authorize @family + + service = Families::Create.new( + user: current_user, + name: family_params[:name] + ) + + if service.call + redirect_to family_path, notice: 'Family created successfully!' + else + @family = Family.new(family_params) + + if service.errors.any? + service.errors.each do |error| + @family.errors.add(error.attribute, error.message) + end + end + + if service.error_message.present? + @family.errors.add(:base, service.error_message) + end + + flash.now[:alert] = service.error_message || 'Failed to create family' + render :new, status: :unprocessable_content + end + end + + def edit + authorize @family + end + + def update + authorize @family + + if @family.update(family_params) + redirect_to family_path, notice: 'Family updated successfully!' + else + render :edit, status: :unprocessable_content + end + end + + def destroy + authorize @family + + if @family.members.count > 1 + redirect_to family_path, alert: 'Cannot delete family with members. Remove all members first.' + else + @family.destroy + redirect_to new_family_path, notice: 'Family deleted successfully!' + end + end + + def update_location_sharing + result = Families::UpdateLocationSharing.new( + user: current_user, + enabled: params[:enabled], + duration: params[:duration] + ).call + + render json: result.payload, status: result.status + end + + private + + def set_family + @family = current_user.family + redirect_to new_family_path, alert: 'You are not in a family' unless @family + end + + def family_params + params.require(:family).permit(:name) + end +end diff --git a/app/controllers/family/invitations_controller.rb b/app/controllers/family/invitations_controller.rb new file mode 100644 index 00000000..040451c1 --- /dev/null +++ b/app/controllers/family/invitations_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class Family::InvitationsController < ApplicationController + before_action :authenticate_user!, except: %i[show] + before_action :ensure_family_feature_enabled!, except: %i[show] + before_action :set_family, except: %i[show] + before_action :set_invitation_by_id_and_family, only: %i[destroy] + + def index + authorize @family, :show? + + @pending_invitations = @family.family_invitations.active + end + + def show + @invitation = Family::Invitation.find_by!(token: params[:token]) + + if @invitation.expired? + redirect_to root_path, alert: 'This invitation has expired.' and return + end + + unless @invitation.pending? + redirect_to root_path, alert: 'This invitation is no longer valid.' and return + end + end + + def create + authorize @family, :invite? + + service = Families::Invite.new( + family: @family, + email: invitation_params[:email], + invited_by: current_user + ) + + if service.call + redirect_to family_path, notice: 'Invitation sent successfully!' + else + redirect_to family_path, alert: service.error_message || 'Failed to send invitation' + end + end + + def destroy + authorize @family, :manage_invitations? + + begin + if @invitation.update(status: :cancelled) + redirect_to family_path, notice: 'Invitation cancelled' + else + redirect_to family_path, alert: 'Failed to cancel invitation. Please try again' + end + rescue StandardError => e + Rails.logger.error "Error cancelling family invitation: #{e.message}" + redirect_to family_path, alert: 'An unexpected error occurred while cancelling the invitation' + end + end + + private + + def set_family + @family = current_user.family + + redirect_to new_family_path, alert: 'You are not in a family' and return unless @family + end + + def set_invitation_by_id_and_family + # For authenticated nested routes: /families/:family_id/invitations/:id + # The :id param contains the token value + @family = current_user.family + @invitation = @family.family_invitations.find_by!(token: params[:id]) + end + + def invitation_params + params.require(:family_invitation).permit(:email) + end +end diff --git a/app/controllers/family/memberships_controller.rb b/app/controllers/family/memberships_controller.rb new file mode 100644 index 00000000..a236ac23 --- /dev/null +++ b/app/controllers/family/memberships_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class Family::MembershipsController < ApplicationController + before_action :authenticate_user! + before_action :ensure_family_feature_enabled! + before_action :set_family, except: %i[create] + before_action :set_membership, only: %i[destroy] + before_action :set_invitation, only: %i[create] + + def create + authorize @invitation, policy_class: Family::MembershipPolicy + + service = Families::AcceptInvitation.new( + invitation: @invitation, + user: current_user + ) + + if service.call + redirect_to family_path, notice: 'Welcome to the family!' + else + redirect_to root_path, alert: service.error_message || 'Unable to accept invitation' + end + rescue Pundit::NotAuthorizedError + if @invitation.expired? + redirect_to root_path, alert: 'This invitation is no longer valid or has expired' + elsif !@invitation.pending? + redirect_to root_path, alert: 'This invitation has already been processed' + elsif @invitation.email != current_user.email + redirect_to root_path, alert: 'This invitation is not for your email address' + else + redirect_to root_path, alert: 'You are not authorized to accept this invitation' + end + rescue StandardError => e + Rails.logger.error "Error accepting family invitation: #{e.message}" + redirect_to root_path, alert: 'An unexpected error occurred. Please try again later' + end + + def destroy + authorize @membership + + member_user = @membership.user + service = Families::Memberships::Destroy.new(user: current_user, member_to_remove: member_user) + + if service.call + if member_user == current_user + redirect_to new_family_path, notice: 'You have left the family' + else + redirect_to family_path, notice: "#{member_user.email} has been removed from the family" + end + else + redirect_to family_path, alert: service.error_message || 'Failed to remove member' + end + end + + private + + def set_family + @family = current_user.family + + redirect_to new_family_path, alert: 'You are not in a family' and return unless @family + end + + def set_membership + @membership = @family.family_memberships.find(params[:id]) + end + + def set_invitation + @invitation = Family::Invitation.find_by!(token: params[:token]) + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index f77c7a54..27453c76 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -6,6 +6,6 @@ class HomeController < ApplicationController redirect_to map_url if current_user - @points = current_user.tracked_points.without_raw_data if current_user + @points = current_user.points.without_raw_data if current_user end end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 2d7feef1..96049978 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -13,9 +13,9 @@ class ImportsController < ApplicationController def index @imports = policy_scope(Import) - .select(:id, :name, :source, :created_at, :processed, :status) - .order(created_at: :desc) - .page(params[:page]) + .select(:id, :name, :source, :created_at, :processed, :status) + .order(created_at: :desc) + .page(params[:page]) end def show; end @@ -43,8 +43,7 @@ class ImportsController < ApplicationController raw_files = Array(files_params).reject(&:blank?) if raw_files.empty? - redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity - return + redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_content and return end created_imports = [] @@ -59,11 +58,11 @@ class ImportsController < ApplicationController if created_imports.any? redirect_to imports_url, notice: "#{created_imports.size} files are queued to be imported in background", - status: :see_other + status: :see_other and return else redirect_to new_import_path, alert: 'No valid file references were found. Please upload files using the file selector.', - status: :unprocessable_entity + status: :unprocessable_content and return end rescue StandardError => e if created_imports.present? @@ -75,7 +74,7 @@ class ImportsController < ApplicationController Rails.logger.error e.backtrace.join("\n") ExceptionReporter.call(e) - redirect_to new_import_path, alert: e.message, status: :unprocessable_entity + redirect_to new_import_path, alert: e.message, status: :unprocessable_content end def destroy @@ -95,7 +94,7 @@ class ImportsController < ApplicationController end def import_params - params.require(:import).permit(:name, :source, files: []) + params.require(:import).permit(:name, files: []) end def create_import_from_signed_id(signed_id) @@ -103,11 +102,8 @@ class ImportsController < ApplicationController blob = ActiveStorage::Blob.find_signed(signed_id) - import = current_user.imports.build( - name: blob.filename.to_s, - source: params[:import][:source] - ) - + import_name = generate_unique_import_name(blob.filename.to_s) + import = current_user.imports.build(name: import_name) import.file.attach(blob) import.save! @@ -115,9 +111,21 @@ class ImportsController < ApplicationController import end + def generate_unique_import_name(original_name) + return original_name unless current_user.imports.exists?(name: original_name) + + # Extract filename and extension + basename = File.basename(original_name, File.extname(original_name)) + extension = File.extname(original_name) + + # Add current datetime + timestamp = Time.current.strftime('%Y%m%d_%H%M%S') + "#{basename}_#{timestamp}#{extension}" + end + def validate_points_limit limit_exceeded = PointsLimitExceeded.new(current_user).call - redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_entity if limit_exceeded + redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_content if limit_exceeded end end diff --git a/app/controllers/map_controller.rb b/app/controllers/map_controller.rb index f75f75d7..bffc5461 100644 --- a/app/controllers/map_controller.rb +++ b/app/controllers/map_controller.rb @@ -13,6 +13,7 @@ class MapController < ApplicationController @end_at = parsed_end_at @years = years_range @points_number = points_count + @features = DawarichSettings.features end private @@ -37,6 +38,8 @@ class MapController < ApplicationController end def calculate_distance + return 0 if @coordinates.size < 2 + total_distance = 0 @coordinates.each_cons(2) do @@ -89,6 +92,6 @@ class MapController < ApplicationController end def points_from_user - current_user.tracked_points.without_raw_data.order(timestamp: :asc) + current_user.points.without_raw_data.order(timestamp: :asc) end end diff --git a/app/controllers/points_controller.rb b/app/controllers/points_controller.rb index a78c97c4..65d99698 100644 --- a/app/controllers/points_controller.rb +++ b/app/controllers/points_controller.rb @@ -24,7 +24,7 @@ class PointsController < ApplicationController alert: 'No points selected.', status: :see_other and return if point_ids.blank? - current_user.tracked_points.where(id: point_ids).destroy_all + current_user.points.where(id: point_ids).destroy_all redirect_to points_url(preserved_params), notice: 'Points were successfully destroyed.', @@ -58,7 +58,7 @@ class PointsController < ApplicationController end def user_points - current_user.tracked_points + current_user.points end def order_by diff --git a/app/controllers/settings/background_jobs_controller.rb b/app/controllers/settings/background_jobs_controller.rb index 31bda769..b9f3a597 100644 --- a/app/controllers/settings/background_jobs_controller.rb +++ b/app/controllers/settings/background_jobs_controller.rb @@ -6,7 +6,7 @@ class Settings::BackgroundJobsController < ApplicationController %w[start_immich_import start_photoprism_import].include?(params[:job_name]) } - def index;end + def index; end def create EnqueueBackgroundJob.perform_later(params[:job_name], current_user.id) diff --git a/app/controllers/settings/users_controller.rb b/app/controllers/settings/users_controller.rb index f00a28ce..d60c5bf9 100644 --- a/app/controllers/settings/users_controller.rb +++ b/app/controllers/settings/users_controller.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true class Settings::UsersController < ApplicationController - before_action :authenticate_self_hosted!, except: [:export, :import] - before_action :authenticate_admin!, except: [:export, :import] + before_action :authenticate_self_hosted!, except: %i[export import] before_action :authenticate_user! + before_action :authenticate_admin!, except: %i[export import] def index @users = User.order(created_at: :desc) @@ -19,7 +19,7 @@ class Settings::UsersController < ApplicationController if @user.update(user_params) redirect_to settings_users_url, notice: 'User was successfully updated.' else - redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_entity + redirect_to settings_users_url, notice: 'User could not be updated.', status: :unprocessable_content end end @@ -33,7 +33,7 @@ class Settings::UsersController < ApplicationController if @user.save redirect_to settings_users_url, notice: 'User was successfully created' else - redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_entity + redirect_to settings_users_url, notice: 'User could not be created.', status: :unprocessable_content end end @@ -43,7 +43,7 @@ class Settings::UsersController < ApplicationController if @user.destroy redirect_to settings_url, notice: 'User was successfully deleted.' else - redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_entity + redirect_to settings_url, notice: 'User could not be deleted.', status: :unprocessable_content end end @@ -54,21 +54,13 @@ class Settings::UsersController < ApplicationController end def import - unless params[:archive].present? + if params[:archive].blank? redirect_to edit_user_registration_path, alert: 'Please select a ZIP archive to import.' return end - archive_file = params[:archive] - - validate_archive_file(archive_file) - - import = current_user.imports.build( - name: archive_file.original_filename, - source: :user_data_archive - ) - - import.file.attach(archive_file) + import = + create_import_from_signed_archive_id(params[:archive]) if import.save redirect_to edit_user_registration_path, @@ -89,12 +81,49 @@ class Settings::UsersController < ApplicationController params.require(:user).permit(:email, :password) end + def create_import_from_signed_archive_id(signed_id) + Rails.logger.debug "Creating archive import from signed ID: #{signed_id[0..20]}..." + + blob = ActiveStorage::Blob.find_signed(signed_id) + + # Validate that it's a ZIP file + validate_blob_file_type(blob) + + import_name = generate_unique_import_name(blob.filename.to_s) + import = current_user.imports.build( + name: import_name, + source: :user_data_archive + ) + import.file.attach(blob) + + import + end + + def generate_unique_import_name(original_name) + return original_name unless current_user.imports.exists?(name: original_name) + + # Extract filename and extension + basename = File.basename(original_name, File.extname(original_name)) + extension = File.extname(original_name) + + # Add current datetime + timestamp = Time.current.strftime('%Y%m%d_%H%M%S') + "#{basename}_#{timestamp}#{extension}" + end + def validate_archive_file(archive_file) - unless archive_file.content_type == 'application/zip' || - archive_file.content_type == 'application/x-zip-compressed' || + unless ['application/zip', 'application/x-zip-compressed'].include?(archive_file.content_type) || File.extname(archive_file.original_filename).downcase == '.zip' redirect_to edit_user_registration_path, alert: 'Please upload a valid ZIP file.' and return end end + + def validate_blob_file_type(blob) + unless ['application/zip', 'application/x-zip-compressed'].include?(blob.content_type) || + File.extname(blob.filename.to_s).downcase == '.zip' + + raise StandardError, 'Please upload a valid ZIP file.' + end + end end diff --git a/app/controllers/shared/stats_controller.rb b/app/controllers/shared/stats_controller.rb new file mode 100644 index 00000000..a9b83862 --- /dev/null +++ b/app/controllers/shared/stats_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class Shared::StatsController < ApplicationController + before_action :authenticate_user!, except: [:show] + before_action :authenticate_active_user!, only: [:update] + + def show + @stat = Stat.find_by(sharing_uuid: params[:uuid]) + + unless @stat&.public_accessible? + return redirect_to root_path, + alert: 'Shared stats not found or no longer available' + end + + @year = @stat.year + @month = @stat.month + @user = @stat.user + @is_public_view = true + @data_bounds = @stat.calculate_data_bounds + @hexagons_available = @stat.hexagons_available? + + render 'stats/public_month' + end + + def update + @year = params[:year].to_i + @month = params[:month].to_i + @stat = current_user.stats.find_by(year: @year, month: @month) + + return head :not_found unless @stat + + if params[:enabled] == '1' + @stat.enable_sharing!(expiration: params[:expiration] || '24h') + sharing_url = shared_stat_url(@stat.sharing_uuid) + + render json: { + success: true, + sharing_url: sharing_url, + message: 'Sharing enabled successfully' + } + else + @stat.disable_sharing! + + render json: { + success: true, + message: 'Sharing disabled successfully' + } + end + rescue StandardError + render json: { + success: false, + message: 'Failed to update sharing settings' + }, status: :unprocessable_content + end +end diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index 710f9b60..8d735acf 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -16,6 +16,14 @@ class StatsController < ApplicationController @year_distances = { @year => Stat.year_distance(@year, current_user) } end + def month + @year = params[:year].to_i + @month = params[:month].to_i + @stat = current_user.stats.find_by(year: @year, month: @month) + @previous_stat = current_user.stats.find_by(year: @year, month: @month - 1) if @month > 1 + @average_distance_this_year = current_user.stats.where(year: @year).average(:distance).to_i / 1000 + end + def update if params[:month] == 'all' (1..12).each do |month| diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb index 1880002b..00764a96 100644 --- a/app/controllers/trips_controller.rb +++ b/app/controllers/trips_controller.rb @@ -16,9 +16,9 @@ class TripsController < ApplicationController end @photo_sources = @trip.photo_sources - if @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank? - Trips::CalculateAllJob.perform_later(@trip.id, current_user.safe_settings.distance_unit) - end + return unless @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank? + + Trips::CalculateAllJob.perform_later(@trip.id, current_user.safe_settings.distance_unit) end def new @@ -34,7 +34,7 @@ class TripsController < ApplicationController if @trip.save redirect_to @trip, notice: 'Trip was successfully created. Data is being calculated in the background.' else - render :new, status: :unprocessable_entity + render :new, status: :unprocessable_content end end @@ -42,7 +42,7 @@ class TripsController < ApplicationController if @trip.update(trip_params) redirect_to @trip, notice: 'Trip was successfully updated.', status: :see_other else - render :edit, status: :unprocessable_entity + render :edit, status: :unprocessable_content end end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb new file mode 100644 index 00000000..fd6a448c --- /dev/null +++ b/app/controllers/users/registrations_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Users::RegistrationsController < Devise::RegistrationsController + before_action :set_invitation, only: %i[new create] + before_action :check_registration_allowed, only: %i[new create] + + def new + build_resource({}) + + resource.email = @invitation.email if @invitation + + yield resource if block_given? + + respond_with resource + end + + def create + super do |resource| + if resource.persisted? && @invitation + accept_invitation_for_user(resource) + end + end + end + + protected + + def after_sign_up_path_for(resource) + return family_path if @invitation&.family + + super(resource) + end + + def after_inactive_sign_up_path_for(resource) + return family_path if @invitation&.family + + super(resource) + end + + private + + def check_registration_allowed + return unless self_hosted_mode? + return if valid_invitation_token? + + redirect_to root_path, + alert: 'Registration is not available. Please contact your administrator for access.' + end + + def set_invitation + return unless invitation_token.present? + + @invitation = Family::Invitation.find_by(token: invitation_token) + end + + def self_hosted_mode? + env_value = ENV['SELF_HOSTED'] + return ActiveModel::Type::Boolean.new.cast(env_value) unless env_value.nil? + + false + end + + def valid_invitation_token? + @invitation&.can_be_accepted? + end + + def invitation_token + @invitation_token ||= params[:invitation_token] || + params.dig(:user, :invitation_token) || + session[:invitation_token] + end + + def accept_invitation_for_user(user) + return unless @invitation&.can_be_accepted? + + service = Families::AcceptInvitation.new( + invitation: @invitation, + user: user + ) + + if service.call + flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family." + else + flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}" + end + rescue StandardError => e + Rails.logger.error "Error accepting invitation during registration: #{e.message}" + flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again." + end + + def sign_up_params + super + end +end diff --git a/app/controllers/users/sessions_controller.rb b/app/controllers/users/sessions_controller.rb new file mode 100644 index 00000000..151bddc5 --- /dev/null +++ b/app/controllers/users/sessions_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Users::SessionsController < Devise::SessionsController + before_action :load_invitation_context, only: [:new] + + def new + super + end + + protected + + def after_sign_in_path_for(resource) + if invitation_token.present? + invitation = Family::Invitation.find_by(token: invitation_token) + + if invitation&.can_be_accepted? + return family_invitation_path(invitation.token) + end + end + + super(resource) + end + + private + + def load_invitation_context + return unless invitation_token.present? + + @invitation = Family::Invitation.find_by(token: invitation_token) + end + + def invitation_token + @invitation_token ||= params[:invitation_token] || session[:invitation_token] + end +end diff --git a/app/controllers/visits_controller.rb b/app/controllers/visits_controller.rb index a22e60e5..bc8c1d8c 100644 --- a/app/controllers/visits_controller.rb +++ b/app/controllers/visits_controller.rb @@ -22,7 +22,7 @@ class VisitsController < ApplicationController if @visit.update(visit_params) redirect_back(fallback_location: visits_path(status: :suggested)) else - render :edit, status: :unprocessable_entity + render :edit, status: :unprocessable_content end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5fdcd917..391b6e30 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,12 +1,23 @@ # frozen_string_literal: true module ApplicationHelper - def classes_for_flash(flash_type) - case flash_type.to_sym - when :error - 'bg-red-100 text-red-700 border-red-300' + def flash_alert_class(type) + case type.to_sym + when :notice, :success then 'alert-success' + when :alert, :error then 'alert-error' + when :warning then 'alert-warning' + when :info then 'alert-info' + else 'alert-info' + end + end + + def flash_icon(type) + case type.to_sym + when :notice, :success then icon 'circle-check' + when :alert, :error then icon 'circle-x' + when :warning then icon 'circle-alert' else - 'bg-blue-100 text-blue-700 border-blue-300' + icon 'info' end end @@ -17,80 +28,10 @@ module ApplicationHelper { start_at:, end_at: } end - def timespan(month, year) - month = DateTime.new(year, month) - start_at = month.beginning_of_month.to_time.strftime('%Y-%m-%dT%H:%M') - end_at = month.end_of_month.to_time.strftime('%Y-%m-%dT%H:%M') - - { start_at:, end_at: } - end - def header_colors %w[info success warning error accent secondary primary] end - def countries_and_cities_stat_for_year(year, stats) - data = { countries: [], cities: [] } - - stats.select { _1.year == year }.each do - data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact - data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq - end - - data[:cities].flatten!.uniq! - data[:countries].flatten!.uniq! - - grouped_by_country = {} - stats.select { _1.year == year }.each do |stat| - stat.toponyms.flatten.each do |toponym| - country = toponym['country'] - next unless country.present? - - grouped_by_country[country] ||= [] - - next unless toponym['cities'].present? - - toponym['cities'].each do |city_data| - city = city_data['city'] - grouped_by_country[country] << city if city.present? - end - end - end - - grouped_by_country.transform_values!(&:uniq) - - { - countries_count: data[:countries].count, - cities_count: data[:cities].count, - grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h, - year: year, - modal_id: "countries_cities_modal_#{year}" - } - end - - def countries_and_cities_stat_for_month(stat) - countries = stat.toponyms.count { _1['country'] } - cities = stat.toponyms.sum { _1['cities'].count } - - "#{countries} countries, #{cities} cities" - end - - def year_distance_stat(year, user) - # Distance is now stored in meters, convert to user's preferred unit for display - total_distance_meters = Stat.year_distance(year, user).sum { _1[1] } - Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit) - end - - def past?(year, month) - DateTime.new(year, month).past? - end - - def points_exist?(year, month, user) - user.tracked_points.where( - timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month - ).exists? - end - def new_version_available? CheckAppVersion.new.call end diff --git a/app/helpers/stats_helper.rb b/app/helpers/stats_helper.rb new file mode 100644 index 00000000..51c129e8 --- /dev/null +++ b/app/helpers/stats_helper.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +module StatsHelper + def year_distance_stat(year_data, user) + total_distance_meters = year_data.sum { _1[1] } + + Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit) + end + + def countries_and_cities_stat_for_year(year, stats) + data = { countries: [], cities: [] } + + stats.select { _1.year == year }.each do + data[:countries] << _1.toponyms.flatten.map { |t| t['country'] }.uniq.compact + data[:cities] << _1.toponyms.flatten.flat_map { |t| t['cities'].map { |c| c['city'] } }.compact.uniq + end + + data[:cities].flatten!.uniq! + data[:countries].flatten!.uniq! + + grouped_by_country = {} + stats.select { _1.year == year }.each do |stat| + stat.toponyms.flatten.each do |toponym| + country = toponym['country'] + next if country.blank? + + grouped_by_country[country] ||= [] + + next if toponym['cities'].blank? + + toponym['cities'].each do |city_data| + city = city_data['city'] + grouped_by_country[country] << city if city.present? + end + end + end + + grouped_by_country.transform_values!(&:uniq) + + { + countries_count: data[:countries].count, + cities_count: data[:cities].count, + grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h, + year: year, + modal_id: "countries_cities_modal_#{year}" + } + end + + def countries_and_cities_stat_for_month(stat) + countries = stat.toponyms.count { _1['country'] } + cities = stat.toponyms.sum { _1['cities'].count } + + "#{countries} countries, #{cities} cities" + end + + def distance_traveled(user, stat) + distance_unit = user.safe_settings.distance_unit + value = Stat.convert_distance(stat.distance, distance_unit).round + + "#{number_with_delimiter(value)} #{distance_unit}" + end + + def x_than_average_distance(stat, average_distance_this_year) + return '' if average_distance_this_year&.zero? + + current_km = stat.distance / 1000.0 + difference = current_km - average_distance_this_year.to_f + percentage = ((difference / average_distance_this_year.to_f) * 100).round + + more_or_less = difference.positive? ? 'more' : 'less' + "#{percentage.abs}% #{more_or_less} than your average this year" + end + + def x_than_previous_active_days(stat, previous_stat) + return '' unless previous_stat + + previous_active_days = previous_stat.daily_distance.select { _1[1].positive? }.count + current_active_days = stat.daily_distance.select { _1[1].positive? }.count + difference = current_active_days - previous_active_days + + return 'Same as previous month' if difference.zero? + + more_or_less = difference.positive? ? 'more' : 'less' + days_word = pluralize(difference.abs, 'day') + + "#{days_word} #{more_or_less} than previous month" + end + + def active_days(stat) + total_days = stat.daily_distance.count + active_days = stat.daily_distance.select { _1[1].positive? }.count + + "#{active_days}/#{total_days}" + end + + def countries_visited(stat) + stat.toponyms.count { _1['country'] } + end + + def x_than_previous_countries_visited(stat, previous_stat) + return '' unless previous_stat + + previous_countries = previous_stat.toponyms.count { _1['country'] } + current_countries = stat.toponyms.count { _1['country'] } + difference = current_countries - previous_countries + + return 'Same as previous month' if difference.zero? + + more_or_less = difference.positive? ? 'more' : 'less' + countries_word = pluralize(difference.abs, 'country') + + "#{countries_word} #{more_or_less} than previous month" + end + + def peak_day(stat) + peak = stat.daily_distance.max_by { _1[1] } + return 'N/A' unless peak && peak[1].positive? + + date = Date.new(stat.year, stat.month, peak[0]) + distance_unit = stat.user.safe_settings.distance_unit + + distance_value = Stat.convert_distance(peak[1], distance_unit).round + text = "#{date.strftime('%B %d')} (#{distance_value} #{distance_unit})" + + link_to text, map_url(start_at: date.beginning_of_day, end_at: date.end_of_day), class: 'underline' + end + + def quietest_week(stat) + return 'N/A' if stat.daily_distance.empty? + + distance_by_date = build_distance_by_date_hash(stat) + quietest_start_date = find_quietest_week_start_date(stat, distance_by_date) + + return 'N/A' unless quietest_start_date + + format_week_range(quietest_start_date) + end + + private + + def build_distance_by_date_hash(stat) + stat.daily_distance.to_h.transform_keys do |day_number| + Date.new(stat.year, stat.month, day_number) + end + end + + def find_quietest_week_start_date(stat, distance_by_date) + quietest_start_date = nil + quietest_distance = Float::INFINITY + stat_month_start = Date.new(stat.year, stat.month, 1) + stat_month_end = stat_month_start.end_of_month + + (stat_month_start..(stat_month_end - 6.days)).each do |start_date| + week_dates = (start_date..(start_date + 6.days)).to_a + week_distance = week_dates.sum { |date| distance_by_date[date] || 0 } + + if week_distance < quietest_distance + quietest_distance = week_distance + quietest_start_date = start_date + end + end + + quietest_start_date + end + + def format_week_range(start_date) + end_date = start_date + 6.days + start_str = start_date.strftime('%b %d') + end_str = end_date.strftime('%b %d') + "#{start_str} - #{end_str}" + end + + def month_icon(stat) + case stat.month + when 1..2, 12 then 'snowflake' + when 3..5 then 'flower' + when 6..8 then 'tree-palm' + when 9..11 then 'leaf' + end + end + + def month_color(stat) + case stat.month + when 1 then '#397bb5' + when 2 then '#5A4E9D' + when 3 then '#3B945E' + when 4 then '#7BC96F' + when 5 then '#FFD54F' + when 6 then '#FFA94D' + when 7 then '#FF6B6B' + when 8 then '#FF8C42' + when 9 then '#C97E4F' + when 10 then '#8B4513' + when 11 then '#5A2E2E' + when 12 then '#265d7d' + end + end + + def month_gradient_classes(stat) + case stat.month + when 1 then 'bg-gradient-to-br from-blue-500 to-blue-800' # Winter blue + when 2 then 'bg-gradient-to-bl from-blue-600 to-purple-600' # Purple + when 3 then 'bg-gradient-to-tr from-green-400 to-green-700' # Spring green + when 4 then 'bg-gradient-to-tl from-green-500 to-green-700' # Light green + when 5 then 'bg-gradient-to-br from-yellow-400 to-yellow-600' # Spring yellow + when 6 then 'bg-gradient-to-bl from-orange-400 to-orange-600' # Summer orange + when 7 then 'bg-gradient-to-tr from-red-400 to-red-600' # Summer red + when 8 then 'bg-gradient-to-tl from-orange-600 to-red-400' # Orange-red + when 9 then 'bg-gradient-to-br from-orange-600 to-yellow-400' # Autumn orange + when 10 then 'bg-gradient-to-bl from-yellow-700 to-orange-700' # Autumn brown + when 11 then 'bg-gradient-to-tr from-red-800 to-red-900' # Dark red + when 12 then 'bg-gradient-to-tl from-blue-600 to-blue-700' # Winter dark blue + end + end + + def month_bg_image(stat) + case stat.month + when 1 then image_url('backgrounds/months/anne-nygard-VwzfdVT6_9s-unsplash.jpg') + when 2 then image_url('backgrounds/months/ainars-cekuls-buAAKQiMfoI-unsplash.jpg') + when 3 then image_url('backgrounds/months/ahmad-hasan-xEYWelDHYF0-unsplash.jpg') + when 4 then image_url('backgrounds/months/lily-Rg1nSqXNPN4-unsplash.jpg') + when 5 then image_url('backgrounds/months/milan-de-clercq-YtllSzi2JLY-unsplash.jpg') + when 6 then image_url('backgrounds/months/liana-mikah-6B05zlnPOEc-unsplash.jpg') + when 7 then image_url('backgrounds/months/irina-iriser-fKAl8Oid6zM-unsplash.jpg') + when 8 then image_url('backgrounds/months/nadiia-ploshchenko-ZnDtJaIec_E-unsplash.jpg') + when 9 then image_url('backgrounds/months/gracehues-photography-AYtup7uqimA-unsplash.jpg') + when 10 then image_url('backgrounds/months/babi-hdNa4GCCgbg-unsplash.jpg') + when 11 then image_url('backgrounds/months/foto-phanatic-8LaUOtP-de4-unsplash.jpg') + when 12 then image_url('backgrounds/months/henry-schneider-FqKPySIaxuE-unsplash.jpg') + end + end +end diff --git a/app/helpers/user_helper.rb b/app/helpers/user_helper.rb index b28f55b9..af3a0724 100644 --- a/app/helpers/user_helper.rb +++ b/app/helpers/user_helper.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true module UserHelper - def api_key_qr_code(user) - qrcode = RQRCode::QRCode.new(user.api_key) + def api_key_qr_code(user, size: 6) + json = { 'server_url' => root_url, 'api_key' => user.api_key } + qrcode = RQRCode::QRCode.new(json.to_json) svg = qrcode.as_svg( - color: "000", - fill: "fff", - shape_rendering: "crispEdges", - module_size: 11, + color: '000', + fill: 'fff', + shape_rendering: 'crispEdges', + module_size: size, standalone: true, use_path: true, offset: 5 diff --git a/app/javascript/channels/family_locations_channel.js b/app/javascript/channels/family_locations_channel.js new file mode 100644 index 00000000..bdcf330a --- /dev/null +++ b/app/javascript/channels/family_locations_channel.js @@ -0,0 +1,24 @@ +import consumer from "./consumer" + +// Only create subscription if family feature is enabled +const familyFeaturesElement = document.querySelector('[data-family-members-features-value]'); +const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {}; + +if (features.family) { + consumer.subscriptions.create("FamilyLocationsChannel", { + connected() { + // Connected to family locations channel + }, + + disconnected() { + // Disconnected from family locations channel + }, + + received(data) { + // Pass data to family members controller if it exists + if (window.familyMembersController) { + window.familyMembersController.updateSingleMemberLocation(data); + } + } + }); +} diff --git a/app/javascript/channels/index.js b/app/javascript/channels/index.js index 0c2237ee..382a0dcc 100644 --- a/app/javascript/channels/index.js +++ b/app/javascript/channels/index.js @@ -2,3 +2,4 @@ import "notifications_channel" import "points_channel" import "imports_channel" +import "family_locations_channel" diff --git a/app/javascript/controllers/add_visit_controller.js b/app/javascript/controllers/add_visit_controller.js new file mode 100644 index 00000000..672b0629 --- /dev/null +++ b/app/javascript/controllers/add_visit_controller.js @@ -0,0 +1,453 @@ +import { Controller } from "@hotwired/stimulus"; +import L from "leaflet"; +import { showFlashMessage } from "../maps/helpers"; +import { applyThemeToButton } from "../maps/theme_utils"; + +export default class extends Controller { + static targets = [""]; + static values = { + apiKey: String, + userTheme: String + } + + connect() { + console.log("Add visit controller connected"); + this.map = null; + this.isAddingVisit = false; + this.addVisitMarker = null; + this.addVisitButton = null; + this.currentPopup = null; + this.mapsController = null; + + // Wait for the map to be initialized + this.waitForMap(); + } + + disconnect() { + this.cleanup(); + console.log("Add visit controller disconnected"); + } + + waitForMap() { + // Get the map from the maps controller instance + const mapElement = document.querySelector('[data-controller*="maps"]'); + + if (mapElement) { + // Try to get Stimulus controller instance + const stimulusController = this.application.getControllerForElementAndIdentifier(mapElement, 'maps'); + if (stimulusController && stimulusController.map) { + this.map = stimulusController.map; + this.mapsController = stimulusController; + this.apiKeyValue = stimulusController.apiKey; + this.setupAddVisitButton(); + return; + } + } + + // Fallback: check for map container and try to find map instance + const mapContainer = document.getElementById('map'); + if (mapContainer && mapContainer._leaflet_id) { + // Get map instance from Leaflet registry + this.map = window.L._getMap ? window.L._getMap(mapContainer._leaflet_id) : null; + + if (!this.map) { + // Try through Leaflet internal registry + const maps = window.L.Map._instances || {}; + this.map = maps[mapContainer._leaflet_id]; + } + + if (this.map) { + // Get API key from map element data + this.apiKeyValue = mapContainer.dataset.api_key || this.element.dataset.apiKey; + this.setupAddVisitButton(); + return; + } + } + + // Wait a bit more for the map to initialize + setTimeout(() => this.waitForMap(), 200); + } + + setupAddVisitButton() { + if (!this.map || this.addVisitButton) return; + + // Create the Add Visit control + const AddVisitControl = L.Control.extend({ + onAdd: (map) => { + const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button'); + button.innerHTML = '➕'; + button.title = 'Add a visit'; + + // Style the button with theme-aware styling + applyThemeToButton(button, this.userThemeValue || 'dark'); + button.style.width = '48px'; + button.style.height = '48px'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.lineHeight = '48px'; + button.style.fontSize = '18px'; + button.style.textAlign = 'center'; + button.style.transition = 'all 0.2s ease'; + + // Disable map interactions when clicking the button + L.DomEvent.disableClickPropagation(button); + + // Toggle add visit mode on button click + L.DomEvent.on(button, 'click', () => { + this.toggleAddVisitMode(button); + }); + + this.addVisitButton = button; + return button; + } + }); + + // Add the control to the map (top right, below existing buttons) + this.map.addControl(new AddVisitControl({ position: 'topright' })); + } + + toggleAddVisitMode(button) { + if (this.isAddingVisit) { + // Exit add visit mode + this.exitAddVisitMode(button); + } else { + // Enter add visit mode + this.enterAddVisitMode(button); + } + } + + enterAddVisitMode(button) { + this.isAddingVisit = true; + + // Update button style to show active state + button.style.backgroundColor = '#dc3545'; + button.style.color = 'white'; + button.innerHTML = '✕'; + + // Change cursor to crosshair + this.map.getContainer().style.cursor = 'crosshair'; + + // Add map click listener + this.map.on('click', this.onMapClick, this); + + showFlashMessage('notice', 'Click on the map to place a visit'); + } + + exitAddVisitMode(button) { + this.isAddingVisit = false; + + // Reset button style with theme-aware styling + applyThemeToButton(button, this.userThemeValue || 'dark'); + button.innerHTML = '➕'; + + // Reset cursor + this.map.getContainer().style.cursor = ''; + + // Remove map click listener + this.map.off('click', this.onMapClick, this); + + // Remove any existing marker + if (this.addVisitMarker) { + this.map.removeLayer(this.addVisitMarker); + this.addVisitMarker = null; + } + + // Close any open popup + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + } + } + + onMapClick(e) { + if (!this.isAddingVisit) return; + + const { lat, lng } = e.latlng; + + // Remove existing marker if any + if (this.addVisitMarker) { + this.map.removeLayer(this.addVisitMarker); + } + + // Create a new marker at the clicked location + this.addVisitMarker = L.marker([lat, lng], { + draggable: true, + icon: L.divIcon({ + className: 'add-visit-marker', + html: '📍', + iconSize: [30, 30], + iconAnchor: [15, 15] + }) + }).addTo(this.map); + + // Show the visit form popup + this.showVisitForm(lat, lng); + } + + showVisitForm(lat, lng) { + // Get current date/time for default values + const now = new Date(); + const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000)); + + // Format dates for datetime-local input + const formatDateTime = (date) => { + return date.toISOString().slice(0, 16); + }; + + const startTime = formatDateTime(now); + const endTime = formatDateTime(oneHourLater); + + // Create form HTML using DaisyUI classes for automatic theme support + const formHTML = ` +
+

Add New Visit

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+
+ `; + + // Create popup at the marker location + this.currentPopup = L.popup({ + closeOnClick: false, + autoClose: false, + maxWidth: 300, + className: 'visit-form-popup' + }) + .setLatLng([lat, lng]) + .setContent(formHTML) + .openOn(this.map); + + // Add event listeners after the popup is added to DOM + setTimeout(() => { + const form = document.getElementById('add-visit-form'); + const cancelButton = document.getElementById('cancel-visit'); + const nameInput = document.getElementById('visit-name'); + + if (form) { + form.addEventListener('submit', (e) => this.handleFormSubmit(e)); + } + + if (cancelButton) { + cancelButton.addEventListener('click', () => { + this.exitAddVisitMode(this.addVisitButton); + }); + } + + // Focus the name input + if (nameInput) { + nameInput.focus(); + } + }, 100); + } + + async handleFormSubmit(event) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + // Get form values + const visitData = { + visit: { + name: formData.get('name'), + started_at: formData.get('started_at'), + ended_at: formData.get('ended_at'), + latitude: formData.get('latitude'), + longitude: formData.get('longitude') + } + }; + + // Validate that end time is after start time + const startTime = new Date(visitData.visit.started_at); + const endTime = new Date(visitData.visit.ended_at); + + if (endTime <= startTime) { + showFlashMessage('error', 'End time must be after start time'); + return; + } + + // Disable form while submitting + const submitButton = form.querySelector('button[type="submit"]'); + const originalText = submitButton.textContent; + submitButton.disabled = true; + submitButton.textContent = 'Creating...'; + + try { + const response = await fetch(`/api/v1/visits`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.apiKeyValue}` + }, + body: JSON.stringify(visitData) + }); + + const data = await response.json(); + + if (response.ok) { + showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`); + this.exitAddVisitMode(this.addVisitButton); + + // Refresh visits layer - this will clear and refetch data + this.refreshVisitsLayer(); + + // Ensure confirmed visits layer is enabled (with a small delay for the API call to complete) + setTimeout(() => { + this.ensureVisitsLayersEnabled(); + }, 300); + } else { + const errorMessage = data.error || data.message || 'Failed to create visit'; + showFlashMessage('error', errorMessage); + } + } catch (error) { + console.error('Error creating visit:', error); + showFlashMessage('error', 'Network error: Failed to create visit'); + } finally { + // Re-enable form + submitButton.disabled = false; + submitButton.textContent = originalText; + } + } + + refreshVisitsLayer() { + console.log('Attempting to refresh visits layer...'); + + // Try multiple approaches to refresh the visits layer + const mapsController = document.querySelector('[data-controller*="maps"]'); + if (mapsController) { + // Try to get the Stimulus controller instance + const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps'); + + if (stimulusController && stimulusController.visitsManager) { + console.log('Found maps controller with visits manager'); + + // Clear existing visits and fetch fresh data + if (stimulusController.visitsManager.visitCircles) { + stimulusController.visitsManager.visitCircles.clearLayers(); + } + if (stimulusController.visitsManager.confirmedVisitCircles) { + stimulusController.visitsManager.confirmedVisitCircles.clearLayers(); + } + + // Refresh the visits data + if (typeof stimulusController.visitsManager.fetchAndDisplayVisits === 'function') { + console.log('Refreshing visits data...'); + stimulusController.visitsManager.fetchAndDisplayVisits(); + } + } else { + console.log('Could not find maps controller or visits manager'); + + // Fallback: Try to dispatch a custom event + const refreshEvent = new CustomEvent('visits:refresh', { bubbles: true }); + mapsController.dispatchEvent(refreshEvent); + } + } else { + console.log('Could not find maps controller element'); + } + } + + ensureVisitsLayersEnabled() { + console.log('Ensuring visits layers are enabled...'); + + const mapsController = document.querySelector('[data-controller*="maps"]'); + if (mapsController) { + const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps'); + + if (stimulusController && stimulusController.map && stimulusController.visitsManager) { + const map = stimulusController.map; + const visitsManager = stimulusController.visitsManager; + + // Get the confirmed visits layer (newly created visits are always confirmed) + const confirmedVisitsLayer = visitsManager.getConfirmedVisitCirclesLayer(); + + // Ensure confirmed visits layer is added to map since we create confirmed visits + if (confirmedVisitsLayer && !map.hasLayer(confirmedVisitsLayer)) { + console.log('Adding confirmed visits layer to map'); + map.addLayer(confirmedVisitsLayer); + + // Update the layer control checkbox to reflect the layer is now active + this.updateLayerControlCheckbox('Confirmed Visits', true); + } + + // Refresh visits data to include the new visit + if (typeof visitsManager.fetchAndDisplayVisits === 'function') { + console.log('Final refresh of visits to show new visit...'); + visitsManager.fetchAndDisplayVisits(); + } + } + } + } + + updateLayerControlCheckbox(layerName, isEnabled) { + // Find the layer control input for the specified layer + const layerControlContainer = document.querySelector('.leaflet-control-layers'); + if (!layerControlContainer) { + console.log('Layer control container not found'); + return; + } + + const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]'); + inputs.forEach(input => { + const label = input.nextElementSibling; + if (label && label.textContent.trim() === layerName) { + console.log(`Updating ${layerName} checkbox to ${isEnabled}`); + input.checked = isEnabled; + + // Trigger change event to ensure proper state management + input.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + } + + cleanup() { + if (this.map) { + this.map.off('click', this.onMapClick, this); + + if (this.addVisitMarker) { + this.map.removeLayer(this.addVisitMarker); + } + + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + } + } + } +} diff --git a/app/javascript/controllers/direct_upload_controller.js b/app/javascript/controllers/direct_upload_controller.js index cc58436e..f278442b 100644 --- a/app/javascript/controllers/direct_upload_controller.js +++ b/app/javascript/controllers/direct_upload_controller.js @@ -6,7 +6,8 @@ export default class extends Controller { static targets = ["input", "progress", "progressBar", "submit", "form"] static values = { url: String, - userTrial: Boolean + userTrial: Boolean, + currentImportsCount: Number } connect() { @@ -51,6 +52,16 @@ export default class extends Controller { const files = this.inputTarget.files if (files.length === 0) return + // Check import count limits for trial users + if (this.userTrialValue && this.currentImportsCountValue >= 5) { + const message = 'Import limit reached. Trial users can only create up to 5 imports. Please subscribe to import more files.' + showFlashMessage('error', message) + + // Clear the file input + this.inputTarget.value = '' + return + } + // Check file size limits for trial users if (this.userTrialValue) { const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes @@ -82,31 +93,33 @@ export default class extends Controller { this.progressTarget.remove() } - // Create a wrapper div for better positioning and visibility + // Create a wrapper div with better DaisyUI styling const progressWrapper = document.createElement("div") - progressWrapper.className = "mt-4 mb-6 border p-4 rounded-lg bg-gray-50" + progressWrapper.className = "w-full mt-4 mb-4" - // Add a label + // Add a label with better typography const progressLabel = document.createElement("div") - progressLabel.className = "font-medium mb-2 text-gray-700" - progressLabel.textContent = "Upload Progress" + progressLabel.className = "text-sm font-medium text-base-content mb-2 flex justify-between items-center" + progressLabel.innerHTML = ` + Upload Progress + 0% + ` progressWrapper.appendChild(progressLabel) - // Create a new progress container - const progressContainer = document.createElement("div") + // Create DaisyUI progress element + const progressContainer = document.createElement("progress") progressContainer.setAttribute("data-direct-upload-target", "progress") - progressContainer.className = "w-full bg-gray-200 rounded-full h-4" + progressContainer.className = "progress progress-primary w-full h-3" + progressContainer.value = 0 + progressContainer.max = 100 - // Create the progress bar fill element + // Create a hidden div for the progress bar target (for compatibility) const progressBarFill = document.createElement("div") progressBarFill.setAttribute("data-direct-upload-target", "progressBar") - progressBarFill.className = "bg-blue-600 h-4 rounded-full transition-all duration-300" - progressBarFill.style.width = "0%" + progressBarFill.style.display = "none" - // Add the fill element to the container - progressContainer.appendChild(progressBarFill) progressWrapper.appendChild(progressContainer) - progressBarFill.dataset.percentageDisplay = "true" + progressWrapper.appendChild(progressBarFill) // Add the progress wrapper AFTER the file input field but BEFORE the submit button this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget) @@ -158,6 +171,19 @@ export default class extends Controller { showFlashMessage('error', 'No files were successfully uploaded. Please try again.') } else { showFlashMessage('notice', `${successfulUploads} file(s) uploaded successfully. Ready to submit.`) + + // Add a completion animation to the progress bar + const percentageDisplay = this.element.querySelector('.progress-percentage') + if (percentageDisplay) { + percentageDisplay.textContent = '100%' + percentageDisplay.classList.add('text-success') + } + + if (this.hasProgressTarget) { + this.progressTarget.value = 100 + this.progressTarget.classList.add('progress-success') + this.progressTarget.classList.remove('progress-primary') + } } this.isUploading = false console.log("All uploads completed") @@ -169,18 +195,20 @@ export default class extends Controller { directUploadWillStoreFileWithXHR(request) { request.upload.addEventListener("progress", event => { - if (!this.hasProgressBarTarget) { - console.warn("Progress bar target not found") + if (!this.hasProgressTarget) { + console.warn("Progress target not found") return } const progress = (event.loaded / event.total) * 100 const progressPercentage = `${progress.toFixed(1)}%` console.log(`Upload progress: ${progressPercentage}`) - this.progressBarTarget.style.width = progressPercentage - // Update text percentage if exists - const percentageDisplay = this.element.querySelector('[data-percentage-display="true"]') + // Update the DaisyUI progress element + this.progressTarget.value = progress + + // Update the percentage display + const percentageDisplay = this.element.querySelector('.progress-percentage') if (percentageDisplay) { percentageDisplay.textContent = progressPercentage } diff --git a/app/javascript/controllers/family_members_controller.js b/app/javascript/controllers/family_members_controller.js new file mode 100644 index 00000000..b77a9273 --- /dev/null +++ b/app/javascript/controllers/family_members_controller.js @@ -0,0 +1,486 @@ +import { Controller } from "@hotwired/stimulus"; +import L from "leaflet"; +import { showFlashMessage } from "../maps/helpers"; + +export default class extends Controller { + static targets = []; + + static values = { + features: Object, + userTheme: String + } + + connect() { + console.log("Family members controller connected"); + + // Wait for maps controller to be ready + this.waitForMap(); + } + + disconnect() { + this.cleanup(); + console.log("Family members controller disconnected"); + } + + waitForMap() { + // Find the maps controller element + const mapElement = document.querySelector('[data-controller*="maps"]'); + if (!mapElement) { + console.warn('Maps controller element not found'); + return; + } + + // Wait for the maps controller to be initialized + const checkMapReady = () => { + if (window.mapsController && window.mapsController.map) { + this.initializeFamilyFeatures(); + } else { + setTimeout(checkMapReady, 100); + } + }; + + checkMapReady(); + } + + initializeFamilyFeatures() { + this.map = window.mapsController.map; + + if (!this.map) { + console.warn('Map not available for family members controller'); + return; + } + + // Initialize family member markers layer + this.familyMarkersLayer = L.layerGroup(); + this.familyMemberLocations = {}; // Object keyed by user_id for efficient updates + this.familyMarkers = {}; // Store marker references by user_id + + // Expose controller globally for ActionCable channel + window.familyMembersController = this; + + // Add to layer control immediately (layer will be empty until data is fetched) + this.addToLayerControl(); + + // Listen for family data updates + this.setupEventListeners(); + } + + createFamilyMarkers() { + // Clear existing family markers + if (this.familyMarkersLayer) { + this.familyMarkersLayer.clearLayers(); + } + + // Clear marker references + this.familyMarkers = {}; + + // Only proceed if family feature is enabled and we have family member locations + if (!this.featuresValue.family || + !this.familyMemberLocations || + Object.keys(this.familyMemberLocations).length === 0) { + return; + } + + const bounds = []; + + Object.values(this.familyMemberLocations).forEach((location) => { + if (!location || !location.latitude || !location.longitude) { + return; + } + + // Get the first letter of the email or use '?' as fallback + const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; + + // Check if this is a recent update (within last 5 minutes) + const isRecent = this.isRecentUpdate(location.updated_at); + const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; + + // Create a distinct marker for family members with email initial + const familyMarker = L.marker([location.latitude, location.longitude], { + icon: L.divIcon({ + html: `
${emailInitial}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + className: markerClass + }) + }); + + // Format timestamp for display + const lastSeen = new Date(location.updated_at).toLocaleString(); + + // Create small tooltip that shows automatically + const tooltipContent = this.createTooltipContent(lastSeen); + const tooltip = familyMarker.bindTooltip(tooltipContent, { + permanent: true, + direction: 'top', + offset: [0, -12], + className: 'family-member-tooltip' + }); + + // Create detailed popup that shows on click + const popupContent = this.createPopupContent(location, lastSeen); + familyMarker.bindPopup(popupContent); + + // Hide tooltip when popup opens, show when popup closes + familyMarker.on('popupopen', () => { + familyMarker.closeTooltip(); + }); + familyMarker.on('popupclose', () => { + familyMarker.openTooltip(); + }); + + this.familyMarkersLayer.addLayer(familyMarker); + + // Store marker reference by user_id for efficient updates + this.familyMarkers[location.user_id] = familyMarker; + + // Add to bounds array for auto-zoom + bounds.push([location.latitude, location.longitude]); + }); + + // Store bounds for later use + this.familyMemberBounds = bounds; + } + + // Update a single family member's location in real-time + updateSingleMemberLocation(locationData) { + if (!this.featuresValue.family) return; + if (!locationData || !locationData.user_id) return; + + // Update stored location data + this.familyMemberLocations[locationData.user_id] = locationData; + + // If the Family Members layer is not currently visible, just store the data + if (!this.map.hasLayer(this.familyMarkersLayer)) { + return; + } + + // Get existing marker for this user + const existingMarker = this.familyMarkers[locationData.user_id]; + + if (existingMarker) { + // Update existing marker position and content + existingMarker.setLatLng([locationData.latitude, locationData.longitude]); + + // Update marker icon with pulse animation for recent updates + const emailInitial = locationData.email_initial || locationData.email?.charAt(0)?.toUpperCase() || '?'; + const isRecent = this.isRecentUpdate(locationData.updated_at); + const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; + + const newIcon = L.divIcon({ + html: `
${emailInitial}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + className: markerClass + }); + existingMarker.setIcon(newIcon); + + // Update tooltip content + const lastSeen = new Date(locationData.updated_at).toLocaleString(); + const tooltipContent = this.createTooltipContent(lastSeen); + existingMarker.setTooltipContent(tooltipContent); + + // Update popup content + const popupContent = this.createPopupContent(locationData, lastSeen); + existingMarker.setPopupContent(popupContent); + } else { + // Create new marker for this user + this.createSingleFamilyMarker(locationData); + } + } + + // Check if location was updated within the last 5 minutes + isRecentUpdate(updatedAt) { + const updateTime = new Date(updatedAt); + const now = new Date(); + const diffMinutes = (now - updateTime) / 1000 / 60; + return diffMinutes < 5; + } + + // Create a marker for a single family member + createSingleFamilyMarker(location) { + if (!location || !location.latitude || !location.longitude) return; + + const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; + const isRecent = this.isRecentUpdate(location.updated_at); + const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; + + const familyMarker = L.marker([location.latitude, location.longitude], { + icon: L.divIcon({ + html: `
${emailInitial}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + className: markerClass + }) + }); + + const lastSeen = new Date(location.updated_at).toLocaleString(); + + const tooltipContent = this.createTooltipContent(lastSeen); + familyMarker.bindTooltip(tooltipContent, { + permanent: true, + direction: 'top', + offset: [0, -12], + className: 'family-member-tooltip' + }); + + const popupContent = this.createPopupContent(location, lastSeen); + familyMarker.bindPopup(popupContent); + + familyMarker.on('popupopen', () => { + familyMarker.closeTooltip(); + }); + familyMarker.on('popupclose', () => { + familyMarker.openTooltip(); + }); + + this.familyMarkersLayer.addLayer(familyMarker); + this.familyMarkers[location.user_id] = familyMarker; + } + + createTooltipContent(lastSeen) { + return `Last updated: ${lastSeen}`; + } + + createPopupContent(location, lastSeen) { + const isDark = this.userThemeValue === 'dark'; + const bgColor = isDark ? '#1f2937' : '#ffffff'; + const textColor = isDark ? '#f9fafb' : '#111827'; + const mutedColor = isDark ? '#9ca3af' : '#6b7280'; + + const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; + + return ` +
+

+ ${emailInitial} + Family Member +

+

+ Email: ${location.email || 'Unknown'} +

+

+ Coordinates:
+ ${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)} +

+

+ Last updated: ${lastSeen} +

+
+ `; + } + + addToLayerControl() { + // Add family markers layer to the maps controller's layer control + if (window.mapsController && window.mapsController.layerControl && this.familyMarkersLayer) { + // We need to recreate the layer control to include our new layer + this.updateMapsControllerLayerControl(); + } + } + + updateMapsControllerLayerControl() { + const mapsController = window.mapsController; + if (!mapsController || typeof mapsController.updateLayerControl !== 'function') return; + + // Use the maps controller's helper method to update layer control + mapsController.updateLayerControl({ + "Family Members": this.familyMarkersLayer + }); + } + + setupEventListeners() { + // Listen for family data updates (for real-time updates in the future) + document.addEventListener('family:locations:updated', (event) => { + this.familyMemberLocations = event.detail.locations; + this.createFamilyMarkers(); + }); + + // Listen for theme changes + document.addEventListener('theme:changed', (event) => { + this.userThemeValue = event.detail.theme; + // Recreate popups with new theme + this.createFamilyMarkers(); + }); + + // Listen for layer control events + this.setupLayerControlEvents(); + } + + setupLayerControlEvents() { + if (!this.map) return; + + // Listen for when the Family Members layer is added + this.map.on('overlayadd', (event) => { + if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) { + // Refresh locations and zoom after data is loaded + this.refreshFamilyLocations().then(() => { + this.zoomToFitAllMembers(); + }); + + // Set up periodic refresh while layer is active + this.startPeriodicRefresh(); + } + }); + + // Listen for when the Family Members layer is removed + this.map.on('overlayremove', (event) => { + if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) { + // Stop periodic refresh when layer is disabled + this.stopPeriodicRefresh(); + } + }); + } + + zoomToFitAllMembers() { + if (!this.familyMemberBounds || this.familyMemberBounds.length === 0) { + return; + } + + // If there's only one member, center on them with a reasonable zoom + if (this.familyMemberBounds.length === 1) { + this.map.setView(this.familyMemberBounds[0], 13); + return; + } + + // For multiple members, fit bounds to show all of them + const bounds = L.latLngBounds(this.familyMemberBounds); + this.map.fitBounds(bounds, { + padding: [50, 50], // Add padding around the edges + maxZoom: 15 // Don't zoom in too close + }); + } + + startPeriodicRefresh() { + // Clear any existing refresh interval + this.stopPeriodicRefresh(); + + // Refresh family locations every 60 seconds while layer is active (as fallback to real-time) + this.refreshInterval = setInterval(() => { + if (this.map && this.map.hasLayer(this.familyMarkersLayer)) { + this.refreshFamilyLocations(); + } else { + // Layer is no longer active, stop refreshing + this.stopPeriodicRefresh(); + } + }, 60000); // 60 seconds (real-time updates via ActionCable are primary) + } + + stopPeriodicRefresh() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + this.refreshInterval = null; + } + } + + // Method to manually update family member locations (for API calls) + updateFamilyLocations(locations) { + // Convert array to object keyed by user_id + if (Array.isArray(locations)) { + this.familyMemberLocations = {}; + locations.forEach(location => { + if (location.user_id) { + this.familyMemberLocations[location.user_id] = location; + } + }); + } else { + this.familyMemberLocations = locations; + } + + this.createFamilyMarkers(); + + // Dispatch event for other controllers that might be interested + document.dispatchEvent(new CustomEvent('family:locations:updated', { + detail: { locations: this.familyMemberLocations } + })); + } + + // Method to refresh family locations from API + async refreshFamilyLocations() { + if (!window.mapsController?.apiKey) { + console.warn('API key not available for family locations refresh'); + return; + } + + try { + const response = await fetch(`/api/v1/families/locations?api_key=${window.mapsController.apiKey}`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + if (response.status === 403) { + console.warn('Family feature not enabled or user not in family'); + return; + } + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + this.updateFamilyLocations(data.locations || []); + + // Show user feedback if this was a manual refresh + if (this.showUserFeedback) { + const count = data.locations?.length || 0; + this.showFlashMessageToUser('notice', `Family locations updated (${count} members)`); + this.showUserFeedback = false; // Reset flag + } + + } catch (error) { + console.error('Error refreshing family locations:', error); + + // Show error to user if this was a manual refresh + if (this.showUserFeedback) { + this.showFlashMessageToUser('error', 'Failed to refresh family locations'); + this.showUserFeedback = false; // Reset flag + } + } + } + + // Helper method to show flash messages using the imported helper + showFlashMessageToUser(type, message) { + showFlashMessage(type, message); + } + + // Method for manual refresh with user feedback + async manualRefreshFamilyLocations() { + this.showUserFeedback = true; // Enable user feedback for this refresh + await this.refreshFamilyLocations(); + } + + cleanup() { + // Stop periodic refresh + this.stopPeriodicRefresh(); + + // Remove family markers layer from map if it exists + if (this.familyMarkersLayer && this.map && this.map.hasLayer(this.familyMarkersLayer)) { + this.map.removeLayer(this.familyMarkersLayer); + } + + // Remove map event listeners + if (this.map) { + this.map.off('overlayadd'); + this.map.off('overlayremove'); + } + + // Remove document event listeners + document.removeEventListener('family:locations:updated', this.handleLocationUpdates); + document.removeEventListener('theme:changed', this.handleThemeChange); + } + + // Expose layer for external access + getFamilyMarkersLayer() { + return this.familyMarkersLayer; + } + + // Check if family features are enabled + isFamilyFeatureEnabled() { + return this.featuresValue.family === true; + } + + // Get family marker count + getFamilyMemberCount() { + return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0; + } +} \ No newline at end of file diff --git a/app/javascript/controllers/family_navbar_indicator_controller.js b/app/javascript/controllers/family_navbar_indicator_controller.js new file mode 100644 index 00000000..80ba1cbc --- /dev/null +++ b/app/javascript/controllers/family_navbar_indicator_controller.js @@ -0,0 +1,48 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["indicator"]; + static values = { + enabled: Boolean + }; + + connect() { + console.log("Family navbar indicator controller connected"); + this.updateIndicator(); + + // Listen for location sharing updates + document.addEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this)); + document.addEventListener('location-sharing:expired', this.handleSharingExpired.bind(this)); + } + + disconnect() { + document.removeEventListener('location-sharing:updated', this.handleSharingUpdate.bind(this)); + document.removeEventListener('location-sharing:expired', this.handleSharingExpired.bind(this)); + } + + handleSharingUpdate(event) { + // Only update if this is the current user's sharing change + // (we're only showing the current user's status in navbar) + this.enabledValue = event.detail.enabled; + this.updateIndicator(); + } + + handleSharingExpired(event) { + this.enabledValue = false; + this.updateIndicator(); + } + + updateIndicator() { + if (!this.hasIndicatorTarget) return; + + if (this.enabledValue) { + // Green pulsing indicator for enabled + this.indicatorTarget.className = "w-2 h-2 bg-green-500 rounded-full animate-pulse"; + this.indicatorTarget.title = "Location sharing enabled"; + } else { + // Gray indicator for disabled + this.indicatorTarget.className = "w-2 h-2 bg-gray-400 rounded-full"; + this.indicatorTarget.title = "Location sharing disabled"; + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/location_sharing_toggle_controller.js b/app/javascript/controllers/location_sharing_toggle_controller.js new file mode 100644 index 00000000..57e3b1f8 --- /dev/null +++ b/app/javascript/controllers/location_sharing_toggle_controller.js @@ -0,0 +1,276 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["checkbox", "durationContainer", "durationSelect", "expirationInfo"]; + static values = { + memberId: Number, + enabled: Boolean, + familyId: Number, + duration: String, + expiresAt: String + }; + + connect() { + console.log("Location sharing toggle controller connected"); + this.updateToggleState(); + this.setupExpirationTimer(); + } + + disconnect() { + this.clearExpirationTimer(); + } + + toggle() { + const newState = !this.enabledValue; + const duration = this.hasDurationSelectTarget ? this.durationSelectTarget.value : 'permanent'; + + // Optimistically update UI + this.enabledValue = newState; + this.updateToggleState(); + + // Send the update to server + this.updateLocationSharing(newState, duration); + } + + changeDuration() { + if (!this.enabledValue) return; // Only allow duration changes when sharing is enabled + + const duration = this.durationSelectTarget.value; + this.durationValue = duration; + + // Update sharing with new duration + this.updateLocationSharing(true, duration); + } + + updateToggleState() { + const isEnabled = this.enabledValue; + + // Update checkbox (DaisyUI toggle) + this.checkboxTarget.checked = isEnabled; + + // Show/hide duration container + if (this.hasDurationContainerTarget) { + if (isEnabled) { + this.durationContainerTarget.classList.remove('hidden'); + } else { + this.durationContainerTarget.classList.add('hidden'); + } + } + } + + async updateLocationSharing(enabled, duration = 'permanent') { + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + + const response = await fetch(`/family/update_location_sharing`, { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + enabled: enabled, + duration: duration + }) + }); + + const data = await response.json(); + + if (data.success) { + // Update local values from server response + this.durationValue = data.duration; + this.expiresAtValue = data.expires_at; + + // Update duration select if it exists + if (this.hasDurationSelectTarget) { + this.durationSelectTarget.value = data.duration; + } + + // Update expiration info + this.updateExpirationInfo(data.expires_at_formatted); + + // Show success message + this.showFlashMessage('success', data.message); + + // Setup/clear expiration timer + this.setupExpirationTimer(); + + // Trigger custom event for other controllers to listen to + document.dispatchEvent(new CustomEvent('location-sharing:updated', { + detail: { + userId: this.memberIdValue, + enabled: enabled, + duration: data.duration, + expiresAt: data.expires_at + } + })); + } else { + // Revert the UI change if server update failed + this.enabledValue = !enabled; + this.updateToggleState(); + this.showFlashMessage('error', data.message || 'Failed to update location sharing'); + } + } catch (error) { + console.error('Error updating location sharing:', error); + + // Revert the UI change if request failed + this.enabledValue = !enabled; + this.updateToggleState(); + this.showFlashMessage('error', 'Network error occurred while updating location sharing'); + } + } + + setupExpirationTimer() { + this.clearExpirationTimer(); + + if (this.enabledValue && this.expiresAtValue) { + const expiresAt = new Date(this.expiresAtValue); + const now = new Date(); + const msUntilExpiration = expiresAt.getTime() - now.getTime(); + + if (msUntilExpiration > 0) { + // Set timer to automatically disable sharing when it expires + this.expirationTimer = setTimeout(() => { + this.enabledValue = false; + this.updateToggleState(); + this.showFlashMessage('info', 'Location sharing has expired'); + + // Trigger update event + document.dispatchEvent(new CustomEvent('location-sharing:expired', { + detail: { userId: this.memberIdValue } + })); + }, msUntilExpiration); + + // Also set up periodic updates to show countdown + this.updateExpirationCountdown(); + this.countdownInterval = setInterval(() => { + this.updateExpirationCountdown(); + }, 60000); // Update every minute + } + } + } + + clearExpirationTimer() { + if (this.expirationTimer) { + clearTimeout(this.expirationTimer); + this.expirationTimer = null; + } + if (this.countdownInterval) { + clearInterval(this.countdownInterval); + this.countdownInterval = null; + } + } + + updateExpirationInfo(formattedTime) { + if (this.hasExpirationInfoTarget && formattedTime) { + this.expirationInfoTarget.textContent = `Expires ${formattedTime}`; + this.expirationInfoTarget.style.display = 'block'; + } else if (this.hasExpirationInfoTarget) { + this.expirationInfoTarget.style.display = 'none'; + } + } + + updateExpirationCountdown() { + if (!this.hasExpirationInfoTarget || !this.expiresAtValue) return; + + const expiresAt = new Date(this.expiresAtValue); + const now = new Date(); + const msUntilExpiration = expiresAt.getTime() - now.getTime(); + + if (msUntilExpiration <= 0) { + this.expirationInfoTarget.textContent = 'Expired'; + this.expirationInfoTarget.style.display = 'block'; + return; + } + + const hoursLeft = Math.floor(msUntilExpiration / (1000 * 60 * 60)); + const minutesLeft = Math.floor((msUntilExpiration % (1000 * 60 * 60)) / (1000 * 60)); + + let timeText; + if (hoursLeft > 0) { + timeText = `${hoursLeft}h ${minutesLeft}m remaining`; + } else { + timeText = `${minutesLeft}m remaining`; + } + + this.expirationInfoTarget.textContent = `Expires in ${timeText}`; + } + + showFlashMessage(type, message) { + // Create a flash message element matching the project style (_flash.html.erb) + const flashContainer = document.getElementById('flash-messages') || + this.createFlashContainer(); + + const bgClass = this.getFlashClasses(type); + + const flashElement = document.createElement('div'); + flashElement.className = `flex items-center ${bgClass} py-3 px-5 rounded-lg z-[6000]`; + flashElement.innerHTML = ` +
${message}
+ + `; + + // Add click handler to dismiss button + const dismissButton = flashElement.querySelector('button'); + dismissButton.addEventListener('click', () => { + flashElement.classList.add('fade-out'); + setTimeout(() => { + flashElement.remove(); + // Remove the container if it's empty + if (flashContainer && !flashContainer.hasChildNodes()) { + flashContainer.remove(); + } + }, 150); + }); + + flashContainer.appendChild(flashElement); + + // Auto-remove after 5 seconds + setTimeout(() => { + if (flashElement.parentNode) { + flashElement.classList.add('fade-out'); + setTimeout(() => { + flashElement.remove(); + // Remove the container if it's empty + if (flashContainer && !flashContainer.hasChildNodes()) { + flashContainer.remove(); + } + }, 150); + } + }, 5000); + } + + createFlashContainer() { + const container = document.createElement('div'); + container.id = 'flash-messages'; + container.className = 'fixed top-5 right-5 flex flex-col gap-2 z-50'; + document.body.appendChild(container); + return container; + } + + getFlashClasses(type) { + switch (type) { + case 'error': + case 'alert': + return 'bg-red-100 text-red-700 border-red-300'; + default: + return 'bg-blue-100 text-blue-700 border-blue-300'; + } + } + + // Helper method to check if user's own location sharing is enabled + // This can be used by other controllers + static getUserLocationSharingStatus() { + const toggleController = document.querySelector('[data-controller*="location-sharing-toggle"]'); + if (toggleController) { + const controller = this.application.getControllerForElementAndIdentifier(toggleController, 'location-sharing-toggle'); + return controller?.enabledValue || false; + } + return false; + } +} diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index a1c22787..9bfa2e45 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -36,12 +36,14 @@ import { fetchAndDisplayPhotos } from "../maps/photos"; import { countryCodesMap } from "../maps/country_codes"; import { VisitsManager } from "../maps/visits"; import { ScratchLayer } from "../maps/scratch_layer"; +import { LocationSearch } from "../maps/location_search"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; import { TileMonitor } from "../maps/tile_monitor"; import BaseController from "./base_controller"; import { createAllMapLayers } from "../maps/layers"; +import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils"; export default class extends BaseController { static targets = ["container"]; @@ -60,6 +62,7 @@ export default class extends BaseController { this.apiKey = this.element.dataset.api_key; this.selfHosted = this.element.dataset.self_hosted; + this.userTheme = this.element.dataset.user_theme || 'dark'; try { this.markers = this.element.dataset.coordinates ? JSON.parse(this.element.dataset.coordinates) : []; @@ -80,6 +83,12 @@ export default class extends BaseController { console.error('Error parsing user_settings data:', error); this.userSettings = {}; } + try { + this.features = this.element.dataset.features ? JSON.parse(this.element.dataset.features) : {}; + } catch (error) { + console.error('Error parsing features data:', error); + this.features = {}; + } this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50; this.fogLineThreshold = parseInt(this.userSettings.fog_of_war_threshold) || 90; // Store route opacity as decimal (0-1) internally @@ -127,10 +136,11 @@ export default class extends BaseController { const unit = this.distanceUnit === 'km' ? 'km' : 'mi'; div.innerHTML = `${distance} ${unit} | ${pointsNumber} points`; - div.style.backgroundColor = 'white'; - div.style.padding = '0 5px'; - div.style.marginRight = '5px'; - div.style.display = 'inline-block'; + applyThemeToControl(div, this.userTheme, { + padding: '0 5px', + marginRight: '5px', + display: 'inline-block' + }); return div; } }); @@ -157,25 +167,28 @@ export default class extends BaseController { // Create a proper Leaflet layer for fog this.fogOverlay = new (createFogOverlay())(); - // Create custom pane for areas + // Create custom panes with proper z-index ordering + // Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700 + + // Areas pane - below visits so they don't block interaction this.map.createPane('areasPane'); - this.map.getPane('areasPane').style.zIndex = 650; - this.map.getPane('areasPane').style.pointerEvents = 'all'; + this.map.getPane('areasPane').style.zIndex = 605; // Above markerPane but below visits + this.map.getPane('areasPane').style.pointerEvents = 'none'; // Don't block clicks, let them pass through - // Create custom panes for visits - // Note: We'll still create visitsPane for backward compatibility + // Legacy visits pane for backward compatibility this.map.createPane('visitsPane'); - this.map.getPane('visitsPane').style.zIndex = 600; - this.map.getPane('visitsPane').style.pointerEvents = 'all'; - - // Create separate panes for confirmed and suggested visits - this.map.createPane('confirmedVisitsPane'); - this.map.getPane('confirmedVisitsPane').style.zIndex = 450; - this.map.getPane('confirmedVisitsPane').style.pointerEvents = 'all'; + this.map.getPane('visitsPane').style.zIndex = 615; + this.map.getPane('visitsPane').style.pointerEvents = 'auto'; + // Suggested visits pane - interactive layer this.map.createPane('suggestedVisitsPane'); - this.map.getPane('suggestedVisitsPane').style.zIndex = 460; - this.map.getPane('suggestedVisitsPane').style.pointerEvents = 'all'; + this.map.getPane('suggestedVisitsPane').style.zIndex = 610; + this.map.getPane('suggestedVisitsPane').style.pointerEvents = 'auto'; + + // Confirmed visits pane - on top of suggested, interactive + this.map.createPane('confirmedVisitsPane'); + this.map.getPane('confirmedVisitsPane').style.zIndex = 620; + this.map.getPane('confirmedVisitsPane').style.pointerEvents = 'auto'; // Initialize areasLayer as a feature group and add it to the map immediately this.areasLayer = new L.FeatureGroup(); @@ -188,7 +201,13 @@ export default class extends BaseController { } // Initialize the visits manager - this.visitsManager = new VisitsManager(this.map, this.apiKey); + this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme); + + // Expose visits manager globally for location search integration + window.visitsManager = this.visitsManager; + + // Expose maps controller globally for family integration + window.mapsController = this; // Initialize layers for the layer control const controlsLayer = { @@ -214,7 +233,9 @@ export default class extends BaseController { this.setupTracksSubscription(); // Handle routes/tracks mode selection - // this.addRoutesTracksSelector(); # Temporarily disabled + if (this.shouldShowTracksSelector()) { + this.addRoutesTracksSelector(); + } this.switchRouteMode('routes', true); // Initialize layers based on settings @@ -237,11 +258,15 @@ export default class extends BaseController { // Initialize Live Map Handler this.initializeLiveMapHandler(); + + // Initialize Location Search + this.initializeLocationSearch(); } disconnect() { super.disconnect(); this.removeEventListeners(); + if (this.tracksSubscription) { this.tracksSubscription.unsubscribe(); } @@ -381,40 +406,28 @@ export default class extends BaseController { // If this is the preferred layer, add it to the map immediately if (selectedLayerName === this.userSettings.maps.name) { - customLayer.addTo(this.map); - // Remove any other base layers that might be active + // Remove any existing base layers first Object.values(maps).forEach(layer => { if (this.map.hasLayer(layer)) { this.map.removeLayer(layer); } }); + customLayer.addTo(this.map); } maps[this.userSettings.maps.name] = customLayer; } else { - // If no custom map is set, ensure a default layer is added - // First check if maps object has any entries + // If no maps were created (fallback case), add OSM if (Object.keys(maps).length === 0) { - // Fallback to OSM if no maps are configured - maps["OpenStreetMap"] = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + console.warn('No map layers available, adding OSM fallback'); + const osmLayer = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }); + osmLayer.addTo(this.map); + maps["OpenStreetMap"] = osmLayer; } - - // Now try to get the selected layer or fall back to alternatives - const defaultLayer = maps[selectedLayerName] || Object.values(maps)[0]; - - if (defaultLayer) { - defaultLayer.addTo(this.map); - } else { - console.error("Could not find any default map layer"); - // Ultimate fallback - create and add OSM layer directly - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: "© OpenStreetMap" - }).addTo(this.map); - } + // Note: createAllMapLayers already added the user's preferred layer to the map } return maps; @@ -645,7 +658,7 @@ export default class extends BaseController { const markerId = parseInt(marker[6]); return markerId !== numericId; }); - + // Update scratch layer manager with updated markers if (this.scratchLayerManager) { this.scratchLayerManager.updateMarkers(this.markers); @@ -716,13 +729,10 @@ export default class extends BaseController { const button = L.DomUtil.create('button', 'map-settings-button'); button.innerHTML = '⚙️'; // Gear icon - // Style the button - button.style.backgroundColor = 'white'; + // Style the button with theme-aware styling + applyThemeToButton(button, this.userTheme); button.style.width = '32px'; button.style.height = '32px'; - button.style.border = 'none'; - button.style.cursor = 'pointer'; - button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(button); @@ -848,11 +858,9 @@ export default class extends BaseController { `; - // Style the panel - div.style.backgroundColor = 'white'; + // Style the panel with theme-aware styling + applyThemeToPanel(div, this.userTheme); div.style.padding = '10px'; - div.style.border = '1px solid #ccc'; - div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; // Prevent map interactions when interacting with the form L.DomEvent.disableClickPropagation(div); @@ -995,6 +1003,16 @@ export default class extends BaseController { const mapElement = document.getElementById('map'); if (mapElement) { mapElement.setAttribute('data-user_settings', JSON.stringify(this.userSettings)); + // Update theme if it changed + if (newSettings.theme && newSettings.theme !== this.userTheme) { + this.userTheme = newSettings.theme; + mapElement.setAttribute('data-user_theme', this.userTheme); + + // Dispatch theme change event for other controllers + document.dispatchEvent(new CustomEvent('theme:changed', { + detail: { theme: this.userTheme } + })); + } } // Store current layer states @@ -1074,19 +1092,25 @@ export default class extends BaseController { const TogglePanelControl = L.Control.extend({ onAdd: function(map) { const button = L.DomUtil.create('button', 'toggle-panel-button'); - button.innerHTML = '📅'; + button.innerHTML = ` + + + + + + + + `; + // Style the button with theme-aware styling + applyThemeToButton(button, controller.userTheme); button.style.width = '48px'; button.style.height = '48px'; - button.style.border = 'none'; - button.style.cursor = 'pointer'; - button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; - button.style.backgroundColor = 'white'; button.style.borderRadius = '4px'; button.style.padding = '0'; - button.style.lineHeight = '48px'; - button.style.fontSize = '18px'; - button.style.textAlign = 'center'; + button.style.display = 'flex'; + button.style.alignItems = 'center'; + button.style.justifyContent = 'center'; // Disable map interactions when clicking the button L.DomEvent.disableClickPropagation(button); @@ -1104,6 +1128,11 @@ export default class extends BaseController { this.map.addControl(new TogglePanelControl({ position: 'topright' })); } + shouldShowTracksSelector() { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('tracks_debug') === 'true'; + } + addRoutesTracksSelector() { // Store reference to the controller instance for use in the control const controller = this; @@ -1111,12 +1140,12 @@ export default class extends BaseController { const RouteTracksControl = L.Control.extend({ onAdd: function(map) { const container = L.DomUtil.create('div', 'routes-tracks-selector leaflet-bar'); - container.style.backgroundColor = 'white'; - container.style.padding = '8px'; - container.style.borderRadius = '4px'; - container.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; - container.style.fontSize = '12px'; - container.style.lineHeight = '1.2'; + applyThemeToControl(container, controller.userTheme, { + padding: '8px', + borderRadius: '4px', + fontSize: '12px', + lineHeight: '1.2' + }); // Get saved preference or default to 'routes' const savedPreference = localStorage.getItem('mapRouteMode') || 'routes'; @@ -1375,10 +1404,8 @@ export default class extends BaseController { this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths); - div.style.backgroundColor = 'white'; + applyThemeToPanel(div, this.userTheme); div.style.padding = '10px'; - div.style.border = '1px solid #ccc'; - div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; div.style.marginRight = '10px'; div.style.marginTop = '10px'; div.style.width = '300px'; @@ -1817,4 +1844,83 @@ export default class extends BaseController { toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible); } } + + initializeLocationSearch() { + if (this.map && this.apiKey && this.features.reverse_geocoding) { + this.locationSearch = new LocationSearch(this.map, this.apiKey, this.userTheme); + } + } + + // Helper method for family controller to update layer control + updateLayerControl(additionalLayers = {}) { + if (!this.layerControl) return; + + // Store which base and overlay layers are currently visible + const overlayStates = {}; + let activeBaseLayer = null; + let activeBaseLayerName = null; + + if (this.layerControl._layers) { + Object.values(this.layerControl._layers).forEach(layerObj => { + if (layerObj.overlay && layerObj.layer) { + // Store overlay layer states + overlayStates[layerObj.name] = this.map.hasLayer(layerObj.layer); + } else if (!layerObj.overlay && this.map.hasLayer(layerObj.layer)) { + // Store the currently active base layer + activeBaseLayer = layerObj.layer; + activeBaseLayerName = layerObj.name; + } + }); + } + + // Remove existing layer control + this.map.removeControl(this.layerControl); + + // Create base controls layer object + const baseControlsLayer = { + Points: this.markersLayer || L.layerGroup(), + Routes: this.polylinesLayer || L.layerGroup(), + Tracks: this.tracksLayer || L.layerGroup(), + Heatmap: this.heatmapLayer || L.heatLayer([]), + "Fog of War": this.fogOverlay, + "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), + Areas: this.areasLayer || L.layerGroup(), + Photos: this.photoMarkers || L.layerGroup(), + "Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(), + "Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup() + }; + + // Merge with additional layers (like family members) + const controlsLayer = { ...baseControlsLayer, ...additionalLayers }; + + // Get base maps and re-add the layer control + const baseMaps = this.baseMaps(); + this.layerControl = L.control.layers(baseMaps, controlsLayer).addTo(this.map); + + // Restore the active base layer if we had one + if (activeBaseLayer && activeBaseLayerName) { + console.log(`Restoring base layer: ${activeBaseLayerName}`); + // Make sure the base layer is added to the map + if (!this.map.hasLayer(activeBaseLayer)) { + activeBaseLayer.addTo(this.map); + } + } else { + // If no active base layer was found, ensure we have a default one + console.log('No active base layer found, adding default'); + const defaultBaseLayer = Object.values(baseMaps)[0]; + if (defaultBaseLayer && !this.map.hasLayer(defaultBaseLayer)) { + defaultBaseLayer.addTo(this.map); + } + } + + // Restore overlay layer visibility states + Object.entries(overlayStates).forEach(([name, wasVisible]) => { + const layer = controlsLayer[name]; + if (layer && wasVisible && !this.map.hasLayer(layer)) { + layer.addTo(this.map); + } + }); + } + + } diff --git a/app/javascript/controllers/public_stat_map_controller.js b/app/javascript/controllers/public_stat_map_controller.js new file mode 100644 index 00000000..a16d5f29 --- /dev/null +++ b/app/javascript/controllers/public_stat_map_controller.js @@ -0,0 +1,321 @@ +import L from "leaflet"; +import { createAllMapLayers } from "../maps/layers"; +import BaseController from "./base_controller"; + +export default class extends BaseController { + static targets = ["container"]; + static values = { + year: Number, + month: Number, + uuid: String, + dataBounds: Object, + hexagonsAvailable: Boolean, + selfHosted: String + }; + + connect() { + super.connect(); + console.log('🏁 Controller connected - loading overlay should be visible'); + this.selfHosted = this.selfHostedValue || 'false'; + this.currentHexagonLayer = null; + this.initializeMap(); + this.loadHexagons(); + } + + disconnect() { + if (this.map) { + this.map.remove(); + } + } + + initializeMap() { + // Initialize map with interactive controls enabled + this.map = L.map(this.element, { + zoomControl: true, + scrollWheelZoom: true, + doubleClickZoom: true, + touchZoom: true, + dragging: true, + keyboard: false + }); + + // Add dynamic tile layer based on self-hosted setting + this.addMapLayers(); + + // Default view with higher zoom level for better hexagon detail + this.map.setView([40.0, -100.0], 9); + } + + addMapLayers() { + try { + // Use appropriate default layer based on self-hosted mode + const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light"; + const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted, 'dark'); + + // If no layers were created, fall back to OSM + if (Object.keys(maps).length === 0) { + console.warn('No map layers available, falling back to OSM'); + this.addFallbackOSMLayer(); + } + } catch (error) { + console.error('Error creating map layers:', error); + console.log('Falling back to OSM tile layer'); + this.addFallbackOSMLayer(); + } + } + + addFallbackOSMLayer() { + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 15 + }).addTo(this.map); + } + + async loadHexagons() { + console.log('🎯 loadHexagons started - checking overlay state'); + const initialLoadingElement = document.getElementById('map-loading'); + console.log('📊 Initial overlay display:', initialLoadingElement?.style.display || 'default'); + + try { + // Use server-provided data bounds + const dataBounds = this.dataBoundsValue; + + if (dataBounds && dataBounds.point_count > 0) { + // Set map view to data bounds BEFORE creating hexagon grid + this.map.fitBounds([ + [dataBounds.min_lat, dataBounds.min_lng], + [dataBounds.max_lat, dataBounds.max_lng] + ], { padding: [20, 20] }); + + // Wait for the map to finish fitting bounds + console.log('⏳ About to wait for map moveend - overlay should still be visible'); + await new Promise(resolve => { + this.map.once('moveend', resolve); + // Fallback timeout in case moveend doesn't fire + setTimeout(resolve, 1000); + }); + console.log('✅ Map fitBounds complete - checking overlay state'); + const afterFitBoundsElement = document.getElementById('map-loading'); + console.log('📊 After fitBounds overlay display:', afterFitBoundsElement?.style.display || 'default'); + } + + console.log('🎯 Public sharing: using manual hexagon loading'); + console.log('🔍 Debug values:'); + console.log(' dataBounds:', dataBounds); + console.log(' point_count:', dataBounds?.point_count); + console.log(' hexagonsAvailableValue:', this.hexagonsAvailableValue); + console.log(' hexagonsAvailableValue type:', typeof this.hexagonsAvailableValue); + + // Load hexagons only if they are pre-calculated and data exists + if (dataBounds && dataBounds.point_count > 0 && this.hexagonsAvailableValue) { + await this.loadStaticHexagons(); + } else { + if (!this.hexagonsAvailableValue) { + console.log('📋 No pre-calculated hexagons available for public sharing - skipping hexagon loading'); + } else { + console.warn('⚠️ No data bounds or points available - not showing hexagons'); + } + // Hide loading indicator if no hexagons to load + const loadingElement = document.getElementById('map-loading'); + if (loadingElement) { + loadingElement.style.display = 'none'; + } + } + + } catch (error) { + console.error('Error initializing hexagon grid:', error); + + // Hide loading indicator on initialization error + const loadingElement = document.getElementById('map-loading'); + if (loadingElement) { + loadingElement.style.display = 'none'; + } + } + + // Do NOT hide loading overlay here - let loadStaticHexagons() handle it completely + } + + async loadStaticHexagons() { + console.log('🔄 Loading static hexagons for public sharing...'); + + // Ensure loading overlay is visible and disable map interaction + const loadingElement = document.getElementById('map-loading'); + console.log('🔍 Loading element found:', !!loadingElement); + if (loadingElement) { + loadingElement.style.display = 'flex'; + loadingElement.style.visibility = 'visible'; + loadingElement.style.zIndex = '9999'; + console.log('👁️ Loading overlay ENSURED visible - should be visible now'); + } + + // Disable map interaction during loading + this.map.dragging.disable(); + this.map.touchZoom.disable(); + this.map.doubleClickZoom.disable(); + this.map.scrollWheelZoom.disable(); + this.map.boxZoom.disable(); + this.map.keyboard.disable(); + if (this.map.tap) this.map.tap.disable(); + + // Add delay to ensure loading overlay is visible + await new Promise(resolve => setTimeout(resolve, 500)); + + try { + // Calculate date range for the month + const startDate = new Date(this.yearValue, this.monthValue - 1, 1); + const endDate = new Date(this.yearValue, this.monthValue, 0, 23, 59, 59); + + // Use the full data bounds for hexagon request (not current map viewport) + const dataBounds = this.dataBoundsValue; + + const params = new URLSearchParams({ + min_lon: dataBounds.min_lng, + min_lat: dataBounds.min_lat, + max_lon: dataBounds.max_lng, + max_lat: dataBounds.max_lat, + start_date: startDate.toISOString(), + end_date: endDate.toISOString(), + uuid: this.uuidValue + }); + + const url = `/api/v1/maps/hexagons?${params}`; + console.log('📍 Fetching static hexagons from:', url); + + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Hexagon API error:', response.status, response.statusText, errorText); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const geojsonData = await response.json(); + console.log(`✅ Loaded ${geojsonData.features?.length || 0} hexagons`); + + // Add hexagons directly to map as a static layer + if (geojsonData.features && geojsonData.features.length > 0) { + this.addStaticHexagonsToMap(geojsonData); + } + + } catch (error) { + console.error('Failed to load static hexagons:', error); + } finally { + // Re-enable map interaction after loading (success or failure) + this.map.dragging.enable(); + this.map.touchZoom.enable(); + this.map.doubleClickZoom.enable(); + this.map.scrollWheelZoom.enable(); + this.map.boxZoom.enable(); + this.map.keyboard.enable(); + if (this.map.tap) this.map.tap.enable(); + + // Hide loading overlay + const loadingElement = document.getElementById('map-loading'); + if (loadingElement) { + loadingElement.style.display = 'none'; + console.log('🚫 Loading overlay hidden - hexagons are fully loaded'); + } + } + } + + addStaticHexagonsToMap(geojsonData) { + // Remove existing hexagon layer if it exists + if (this.currentHexagonLayer) { + this.map.removeLayer(this.currentHexagonLayer); + } + + // Calculate max point count for color scaling + const maxPoints = Math.max(...geojsonData.features.map(f => f.properties.point_count)); + + const staticHexagonLayer = L.geoJSON(geojsonData, { + style: (feature) => this.styleHexagon(), + onEachFeature: (feature, layer) => { + // Add popup with statistics + const props = feature.properties; + const popupContent = this.buildPopupContent(props); + layer.bindPopup(popupContent); + + // Add hover effects + layer.on({ + mouseover: (e) => this.onHexagonMouseOver(e), + mouseout: (e) => this.onHexagonMouseOut(e) + }); + } + }); + + this.currentHexagonLayer = staticHexagonLayer; + staticHexagonLayer.addTo(this.map); + } + + styleHexagon() { + return { + fillColor: '#3388ff', + fillOpacity: 0.3, + color: '#3388ff', + weight: 1, + opacity: 0.3 + }; + } + + buildPopupContent(props) { + const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A'; + const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A'; + const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString() : ''; + const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString() : ''; + + return ` +
+ 📍 Location Data
+
+ Points: ${props.point_count || 0} +
+ ${props.h3_index ? ` +
+ H3 Index:
+ ${props.h3_index} +
+ ` : ''} +
+ Time Range:
+ ${startDate} ${startTime}
→ ${endDate} ${endTime}
+
+ ${props.center ? ` +
+ Center:
+ ${props.center[0].toFixed(6)}, ${props.center[1].toFixed(6)} +
+ ` : ''} +
+ `; + } + + onHexagonMouseOver(e) { + const layer = e.target; + // Store original style before changing + if (!layer._originalStyle) { + layer._originalStyle = { + fillOpacity: layer.options.fillOpacity, + weight: layer.options.weight, + opacity: layer.options.opacity + }; + } + + layer.setStyle({ + fillOpacity: 0.8, + weight: 2, + opacity: 1.0 + }); + } + + onHexagonMouseOut(e) { + const layer = e.target; + // Reset to stored original style + if (layer._originalStyle) { + layer.setStyle(layer._originalStyle); + } + } +} diff --git a/app/javascript/controllers/sharing_modal_controller.js b/app/javascript/controllers/sharing_modal_controller.js new file mode 100644 index 00000000..eb6e9ade --- /dev/null +++ b/app/javascript/controllers/sharing_modal_controller.js @@ -0,0 +1,131 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["enableToggle", "expirationSettings", "sharingLink", "loading", "expirationSelect"] + static values = { url: String } + + connect() { + console.log("Sharing modal controller connected") + } + + toggleSharing() { + const isEnabled = this.enableToggleTarget.checked + + if (isEnabled) { + this.expirationSettingsTarget.classList.remove("hidden") + this.saveSettings() // Save immediately when enabling + } else { + this.expirationSettingsTarget.classList.add("hidden") + this.sharingLinkTarget.value = "" + this.saveSettings() // Save immediately when disabling + } + } + + expirationChanged() { + // Save settings immediately when expiration changes + if (this.enableToggleTarget.checked) { + this.saveSettings() + } + } + + saveSettings() { + // Show loading state + this.showLoadingState() + + const formData = new FormData() + formData.append('enabled', this.enableToggleTarget.checked ? '1' : '0') + + if (this.enableToggleTarget.checked && this.hasExpirationSelectTarget) { + formData.append('expiration', this.expirationSelectTarget.value || '1h') + } else if (this.enableToggleTarget.checked) { + formData.append('expiration', '1h') + } + + // Use the URL value from the controller + const url = this.urlValue + + fetch(url, { + method: 'PATCH', + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content, + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData + }) + .then(response => response.json()) + .then(data => { + this.hideLoadingState() + + if (data.success) { + // Update sharing link if provided + if (data.sharing_url) { + this.sharingLinkTarget.value = data.sharing_url + } + + // Show a subtle notification for auto-save + this.showNotification("✓ Auto-saved", "success") + } else { + this.showNotification("Failed to save settings. Please try again.", "error") + } + }) + .catch(error => { + console.error('Error:', error) + this.hideLoadingState() + this.showNotification("Failed to save settings. Please try again.", "error") + }) + } + + showLoadingState() { + if (this.hasLoadingTarget) { + this.loadingTarget.classList.remove("hidden") + } + } + + hideLoadingState() { + if (this.hasLoadingTarget) { + this.loadingTarget.classList.add("hidden") + } + } + + async copyLink() { + try { + await navigator.clipboard.writeText(this.sharingLinkTarget.value) + + // Show temporary success feedback + const button = this.sharingLinkTarget.nextElementSibling + const originalText = button.innerHTML + button.innerHTML = "✅ Copied!" + button.classList.add("btn-success") + + setTimeout(() => { + button.innerHTML = originalText + button.classList.remove("btn-success") + }, 2000) + + } catch (err) { + console.error("Failed to copy: ", err) + + // Fallback: select the text + this.sharingLinkTarget.select() + this.sharingLinkTarget.setSelectionRange(0, 99999) // For mobile devices + } + } + + showNotification(message, type) { + // Create a simple toast notification + const toast = document.createElement('div') + toast.className = `toast toast-top toast-end z-50` + toast.innerHTML = ` +
+ ${message} +
+ ` + + document.body.appendChild(toast) + + // Remove after 3 seconds + setTimeout(() => { + toast.remove() + }, 3000) + } +} diff --git a/app/javascript/controllers/stat_page_controller.js b/app/javascript/controllers/stat_page_controller.js new file mode 100644 index 00000000..e2e94184 --- /dev/null +++ b/app/javascript/controllers/stat_page_controller.js @@ -0,0 +1,287 @@ +import L from "leaflet"; +import "leaflet.heat"; +import { createAllMapLayers } from "../maps/layers"; +import BaseController from "./base_controller"; + +export default class extends BaseController { + static targets = ["map", "loading", "heatmapBtn", "pointsBtn"]; + + connect() { + super.connect(); + console.log("StatPage controller connected"); + + // Get data attributes from the element (will be passed from the view) + this.year = parseInt(this.element.dataset.year || new Date().getFullYear()); + this.month = parseInt(this.element.dataset.month || new Date().getMonth() + 1); + this.apiKey = this.element.dataset.apiKey; + this.selfHosted = this.element.dataset.selfHosted || this.selfHostedValue; + + console.log(`Loading data for ${this.month}/${this.year} with API key: ${this.apiKey ? 'present' : 'missing'}`); + + // Initialize map after a short delay to ensure container is ready + setTimeout(() => { + this.initializeMap(); + }, 100); + } + + disconnect() { + if (this.map) { + this.map.remove(); + } + console.log("StatPage controller disconnected"); + } + + initializeMap() { + if (!this.mapTarget) { + console.error("Map target not found"); + return; + } + + try { + // Initialize Leaflet map + this.map = L.map(this.mapTarget, { + zoomControl: true, + scrollWheelZoom: true, + doubleClickZoom: true, + boxZoom: false, + keyboard: false, + dragging: true, + touchZoom: true + }).setView([52.520008, 13.404954], 10); // Default to Berlin + + // Add dynamic tile layer based on self-hosted setting + this.addMapLayers(); + + // Add small scale control + L.control.scale({ + position: 'bottomright', + maxWidth: 100, + imperial: true, + metric: true + }).addTo(this.map); + + // Initialize layers + this.markersLayer = L.layerGroup(); // Don't add to map initially + this.heatmapLayer = null; + + // Load data for this month + this.loadMonthData(); + + } catch (error) { + console.error("Error initializing map:", error); + this.showError("Failed to initialize map"); + } + } + + async loadMonthData() { + try { + // Show loading + this.showLoading(true); + + // Calculate date range for the month + const startDate = `${this.year}-${this.month.toString().padStart(2, '0')}-01T00:00:00`; + const lastDay = new Date(this.year, this.month, 0).getDate(); + const endDate = `${this.year}-${this.month.toString().padStart(2, '0')}-${lastDay}T23:59:59`; + + console.log(`Fetching points from ${startDate} to ${endDate}`); + + // Fetch points data for the month using Authorization header + const response = await fetch(`/api/v1/points?start_at=${encodeURIComponent(startDate)}&end_at=${encodeURIComponent(endDate)}&per_page=1000`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + } + }); + + if (!response.ok) { + console.error(`API request failed with status: ${response.status}`); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + console.log(`Received ${Array.isArray(data) ? data.length : 0} points from API`); + + if (Array.isArray(data) && data.length > 0) { + this.processPointsData(data); + } else { + console.log("No points data available for this month"); + this.showNoData(); + } + + } catch (error) { + console.error("Error loading month data:", error); + this.showError("Failed to load location data"); + // Don't fallback to mock data - show the error instead + } finally { + this.showLoading(false); + } + } + + processPointsData(points) { + console.log(`Processing ${points.length} points for ${this.month}/${this.year}`); + + // Clear existing markers + this.markersLayer.clearLayers(); + + // Convert points to markers (API returns latitude/longitude as strings) + const markers = points.map(point => { + const lat = parseFloat(point.latitude); + const lng = parseFloat(point.longitude); + + return L.circleMarker([lat, lng], { + radius: 3, + fillColor: '#570df8', + color: '#570df8', + weight: 1, + opacity: 0.8, + fillOpacity: 0.6 + }); + }); + + // Add markers to layer (but don't add to map yet) + markers.forEach(marker => { + this.markersLayer.addLayer(marker); + }); + + // Prepare data for heatmap (convert strings to numbers) + this.heatmapData = points.map(point => [ + parseFloat(point.latitude), + parseFloat(point.longitude), + 0.5 + ]); + + // Show heatmap by default + if (this.heatmapData.length > 0) { + this.heatmapLayer = L.heatLayer(this.heatmapData, { + radius: 25, + blur: 15, + maxZoom: 17, + max: 1.0 + }).addTo(this.map); + + // Set button states + this.heatmapBtnTarget.classList.add('btn-active'); + this.pointsBtnTarget.classList.remove('btn-active'); + } + + // Fit map to show all points + if (points.length > 0) { + const group = new L.featureGroup(markers); + this.map.fitBounds(group.getBounds().pad(0.1)); + } + + console.log("Points processed successfully"); + } + + toggleHeatmap() { + if (!this.heatmapData || this.heatmapData.length === 0) { + console.warn("No heatmap data available"); + return; + } + + if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) { + // Remove heatmap + this.map.removeLayer(this.heatmapLayer); + this.heatmapLayer = null; + this.heatmapBtnTarget.classList.remove('btn-active'); + + // Show points + if (!this.map.hasLayer(this.markersLayer)) { + this.map.addLayer(this.markersLayer); + this.pointsBtnTarget.classList.add('btn-active'); + } + } else { + // Add heatmap + this.heatmapLayer = L.heatLayer(this.heatmapData, { + radius: 25, + blur: 15, + maxZoom: 17, + max: 1.0 + }).addTo(this.map); + + this.heatmapBtnTarget.classList.add('btn-active'); + + // Hide points + if (this.map.hasLayer(this.markersLayer)) { + this.map.removeLayer(this.markersLayer); + this.pointsBtnTarget.classList.remove('btn-active'); + } + } + } + + togglePoints() { + if (this.map.hasLayer(this.markersLayer)) { + // Remove points + this.map.removeLayer(this.markersLayer); + this.pointsBtnTarget.classList.remove('btn-active'); + } else { + // Add points + this.map.addLayer(this.markersLayer); + this.pointsBtnTarget.classList.add('btn-active'); + + // Remove heatmap if active + if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) { + this.map.removeLayer(this.heatmapLayer); + this.heatmapBtnTarget.classList.remove('btn-active'); + } + } + } + + showLoading(show) { + if (this.hasLoadingTarget) { + this.loadingTarget.style.display = show ? 'flex' : 'none'; + } + } + + showError(message) { + console.error(message); + if (this.hasLoadingTarget) { + this.loadingTarget.innerHTML = ` +
+ + ${message} +
+ `; + this.loadingTarget.style.display = 'flex'; + } + } + + showNoData() { + console.log("No data available for this month"); + if (this.hasLoadingTarget) { + this.loadingTarget.innerHTML = ` +
+ + No location data available for ${new Date(this.year, this.month - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} +
+ `; + this.loadingTarget.style.display = 'flex'; + } + } + + addMapLayers() { + try { + // Use appropriate default layer based on self-hosted mode + const selectedLayerName = this.selfHosted === "true" ? "OpenStreetMap" : "Light"; + const maps = createAllMapLayers(this.map, selectedLayerName, this.selfHosted, 'dark'); + + // If no layers were created, fall back to OSM + if (Object.keys(maps).length === 0) { + console.warn('No map layers available, falling back to OSM'); + this.addFallbackOSMLayer(); + } + } catch (error) { + console.error('Error creating map layers:', error); + console.log('Falling back to OSM tile layer'); + this.addFallbackOSMLayer(); + } + } + + addFallbackOSMLayer() { + L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors' + }).addTo(this.map); + } +} diff --git a/app/javascript/controllers/trip_map_controller.js b/app/javascript/controllers/trip_map_controller.js index c95620e7..588a8e3b 100644 --- a/app/javascript/controllers/trip_map_controller.js +++ b/app/javascript/controllers/trip_map_controller.js @@ -53,7 +53,7 @@ export default class extends BaseController { this.userSettingsValue.preferred_map_layer || "OpenStreetMap" : "OpenStreetMap"; - let maps = createAllMapLayers(this.map, selectedLayerName); + let maps = createAllMapLayers(this.map, selectedLayerName, "false", 'dark'); // Add custom map if it exists in settings if (this.hasUserSettingsValue && this.userSettingsValue.maps && this.userSettingsValue.maps.url) { diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 6275716a..b9c15d2f 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -168,7 +168,7 @@ export default class extends BaseController { baseMaps() { let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; - let maps = createAllMapLayers(this.map, selectedLayerName); + let maps = createAllMapLayers(this.map, selectedLayerName, "false", 'dark'); // Add custom map if it exists in settings if (this.userSettings.maps && this.userSettings.maps.url) { diff --git a/app/javascript/controllers/user_data_archive_direct_upload_controller.js b/app/javascript/controllers/user_data_archive_direct_upload_controller.js new file mode 100644 index 00000000..31444539 --- /dev/null +++ b/app/javascript/controllers/user_data_archive_direct_upload_controller.js @@ -0,0 +1,217 @@ +import { Controller } from "@hotwired/stimulus" +import { DirectUpload } from "@rails/activestorage" +import { showFlashMessage } from "../maps/helpers" + +export default class extends Controller { + static targets = ["input", "progress", "progressBar", "submit", "form"] + static values = { + url: String, + userTrial: Boolean + } + + connect() { + this.inputTarget.addEventListener("change", this.upload.bind(this)) + + // Add form submission handler to disable the file input + if (this.hasFormTarget) { + this.formTarget.addEventListener("submit", this.onSubmit.bind(this)) + } + + // Initially disable submit button if no files are uploaded + if (this.hasSubmitTarget) { + const hasUploadedFiles = this.element.querySelectorAll('input[name="archive"][type="hidden"]').length > 0 + this.submitTarget.disabled = !hasUploadedFiles + } + } + + onSubmit(event) { + if (this.isUploading) { + // If still uploading, prevent submission + event.preventDefault() + console.log("Form submission prevented during upload") + return + } + + // Disable the file input to prevent it from being submitted with the form + // This ensures only our hidden input with signed ID is submitted + this.inputTarget.disabled = true + + // Check if we have a signed ID + const signedId = this.element.querySelector('input[name="archive"][type="hidden"]') + if (!signedId) { + event.preventDefault() + console.log("No file uploaded yet") + alert("Please select and upload a ZIP archive first") + } else { + console.log("Submitting form with uploaded archive") + } + } + + upload() { + const files = this.inputTarget.files + if (files.length === 0) return + + const file = files[0] // Only handle single file for archives + + // Validate file type + if (!this.isValidZipFile(file)) { + showFlashMessage('error', 'Please select a valid ZIP file.') + this.inputTarget.value = '' + return + } + + // Check file size limits for trial users + if (this.userTrialValue) { + const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes + + if (file.size > MAX_FILE_SIZE) { + const message = `File size limit exceeded. Trial users can only upload files up to 10MB. File size: ${(file.size / 1024 / 1024).toFixed(1)}MB` + showFlashMessage('error', message) + + // Clear the file input + this.inputTarget.value = '' + return + } + } + + console.log(`Uploading archive: ${file.name}`) + this.isUploading = true + + // Disable submit button during upload + this.submitTarget.disabled = true + this.submitTarget.classList.add("opacity-50", "cursor-not-allowed") + + // Show uploading message using flash + showFlashMessage('notice', `Uploading ${file.name}, please wait...`) + + // Always remove any existing progress bar to ensure we create a fresh one + if (this.hasProgressTarget) { + this.progressTarget.remove() + } + + // Create a wrapper div with better DaisyUI styling + const progressWrapper = document.createElement("div") + progressWrapper.className = "w-full mt-4 mb-4" + + // Add a label with better typography + const progressLabel = document.createElement("div") + progressLabel.className = "text-sm font-medium text-base-content mb-2 flex justify-between items-center" + progressLabel.innerHTML = ` + Upload Progress + 0% + ` + progressWrapper.appendChild(progressLabel) + + // Create DaisyUI progress element + const progressContainer = document.createElement("progress") + progressContainer.setAttribute("data-user-data-archive-direct-upload-target", "progress") + progressContainer.className = "progress progress-primary w-full h-3" + progressContainer.value = 0 + progressContainer.max = 100 + + // Create a hidden div for the progress bar target (for compatibility) + const progressBarFill = document.createElement("div") + progressBarFill.setAttribute("data-user-data-archive-direct-upload-target", "progressBar") + progressBarFill.style.display = "none" + + progressWrapper.appendChild(progressContainer) + progressWrapper.appendChild(progressBarFill) + + // Add the progress wrapper after the form-control div containing the file input + const formControl = this.inputTarget.closest('.form-control') + if (formControl) { + formControl.parentNode.insertBefore(progressWrapper, formControl.nextSibling) + } else { + // Fallback: insert before submit button + this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget) + } + + console.log("Progress bar created and inserted after file input") + + // Clear any existing hidden field for archive + const existingHiddenField = this.element.querySelector('input[name="archive"][type="hidden"]') + if (existingHiddenField) { + existingHiddenField.remove() + } + + const upload = new DirectUpload(file, this.urlValue, this) + upload.create((error, blob) => { + if (error) { + console.error("Error uploading file:", error) + // Show error to user using flash + showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`) + + // Re-enable submit button but keep it disabled since no file was uploaded + this.submitTarget.disabled = true + this.submitTarget.classList.add("opacity-50", "cursor-not-allowed") + } else { + console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`) + + // Create a hidden field with the correct name + const hiddenField = document.createElement("input") + hiddenField.setAttribute("type", "hidden") + hiddenField.setAttribute("name", "archive") + hiddenField.setAttribute("value", blob.signed_id) + this.element.appendChild(hiddenField) + + console.log("Added hidden field with signed ID:", blob.signed_id) + + // Enable submit button + this.submitTarget.disabled = false + this.submitTarget.classList.remove("opacity-50", "cursor-not-allowed") + + showFlashMessage('notice', `Archive uploaded successfully. Ready to import.`) + + // Add a completion animation to the progress bar + const percentageDisplay = this.element.querySelector('.progress-percentage') + if (percentageDisplay) { + percentageDisplay.textContent = '100%' + percentageDisplay.classList.add('text-success') + } + + if (this.hasProgressTarget) { + this.progressTarget.value = 100 + this.progressTarget.classList.add('progress-success') + this.progressTarget.classList.remove('progress-primary') + } + } + + this.isUploading = false + console.log("Upload completed") + }) + } + + isValidZipFile(file) { + // Check MIME type + const validMimeTypes = ['application/zip', 'application/x-zip-compressed'] + if (validMimeTypes.includes(file.type)) { + return true + } + + // Check file extension as fallback + const filename = file.name.toLowerCase() + return filename.endsWith('.zip') + } + + directUploadWillStoreFileWithXHR(request) { + request.upload.addEventListener("progress", event => { + if (!this.hasProgressTarget) { + console.warn("Progress target not found") + return + } + + const progress = (event.loaded / event.total) * 100 + const progressPercentage = `${progress.toFixed(1)}%` + console.log(`Upload progress: ${progressPercentage}`) + + // Update the DaisyUI progress element + this.progressTarget.value = progress + + // Update the percentage display + const percentageDisplay = this.element.querySelector('.progress-percentage') + if (percentageDisplay) { + percentageDisplay.textContent = progressPercentage + } + }) + } +} \ No newline at end of file diff --git a/app/javascript/maps/layers.js b/app/javascript/maps/layers.js index 6125beef..27e544a4 100644 --- a/app/javascript/maps/layers.js +++ b/app/javascript/maps/layers.js @@ -49,8 +49,19 @@ export function createMapLayer(map, selectedLayerName, layerKey, selfHosted) { export function createAllMapLayers(map, selectedLayerName, selfHosted) { const layers = {}; const mapsConfig = selfHosted === "true" ? rasterMapsConfig : vectorMapsConfig; + + // Determine the default layer based on self-hosted mode + const defaultLayerName = selfHosted === "true" ? "OpenStreetMap" : "Light"; + + // If selectedLayerName is null/undefined or doesn't exist in config, use default + const layerToSelect = selectedLayerName && mapsConfig[selectedLayerName] + ? selectedLayerName + : defaultLayerName; + Object.keys(mapsConfig).forEach(layerKey => { - layers[layerKey] = createMapLayer(map, selectedLayerName, layerKey, selfHosted); + // Create the layer and add it to the map if it's the selected/default layer + const layer = createMapLayer(map, layerToSelect, layerKey, selfHosted); + layers[layerKey] = layer; }); return layers; diff --git a/app/javascript/maps/location_search.js b/app/javascript/maps/location_search.js new file mode 100644 index 00000000..e54ff40f --- /dev/null +++ b/app/javascript/maps/location_search.js @@ -0,0 +1,1164 @@ +// Location search functionality for the map +import { applyThemeToButton } from "./theme_utils"; + +class LocationSearch { + constructor(map, apiKey, userTheme = 'dark') { + this.map = map; + this.apiKey = apiKey; + this.userTheme = userTheme; + this.searchResults = []; + this.searchMarkersLayer = null; + this.currentSearchQuery = ''; + this.searchTimeout = null; + this.suggestionsVisible = false; + this.currentSuggestionIndex = -1; + + // Make instance globally accessible for popup buttons + window.locationSearchInstance = this; + + this.initializeSearchBar(); + } + + initializeSearchBar() { + // Create search toggle button using Leaflet control (positioned below settings button) + const SearchToggleControl = L.Control.extend({ + onAdd: function(map) { + const button = L.DomUtil.create('button', 'location-search-toggle'); + button.innerHTML = '🔍'; + // Style the button with theme-aware styling + applyThemeToButton(button, this.userTheme); + button.style.width = '48px'; + button.style.height = '48px'; + button.style.borderRadius = '4px'; + button.style.padding = '0'; + button.style.fontSize = '18px'; + button.style.marginTop = '10px'; // Space below settings button + button.title = 'Search locations'; + button.id = 'location-search-toggle'; + return button; + } + }); + + // Add the search toggle control to the map + this.map.addControl(new SearchToggleControl({ position: 'topleft' })); + + // Use setTimeout to ensure the DOM element is available + setTimeout(() => { + // Get reference to the created button + const toggleButton = document.getElementById('location-search-toggle'); + + if (toggleButton) { + // Create inline search bar + this.createInlineSearchBar(); + + // Store references + this.toggleButton = toggleButton; + this.searchVisible = false; + + // Bind events + this.bindSearchEvents(); + + console.log('LocationSearch: Search button initialized successfully'); + } else { + console.error('LocationSearch: Could not find search toggle button'); + } + }, 100); + } + + createInlineSearchBar() { + // Create inline search bar that appears next to the search button + const searchBar = document.createElement('div'); + searchBar.className = 'location-search-bar absolute bg-white border border-gray-300 rounded-lg shadow-lg hidden'; + searchBar.id = 'location-search-container'; // Use container ID for test compatibility + searchBar.style.width = '400px'; // Increased width for better usability + searchBar.style.maxHeight = '600px'; // Set max height for the entire search bar + searchBar.style.padding = '12px'; // Increased padding + searchBar.style.zIndex = '9999'; // Very high z-index to ensure visibility + searchBar.style.overflow = 'visible'; // Allow content to overflow but results area will scroll + + searchBar.innerHTML = ` +
+ + +
+ + + + + + + `; + + // Add search bar to the map container + this.map.getContainer().appendChild(searchBar); + + // Store references + this.searchBar = searchBar; + this.searchInput = document.getElementById('location-search-input'); + this.closeButton = document.getElementById('location-search-close'); + this.suggestionsContainer = document.getElementById('location-search-suggestions'); + this.suggestionsPanel = document.getElementById('location-search-suggestions-panel'); + this.resultsContainer = document.getElementById('location-search-results'); + this.resultsPanel = document.getElementById('location-search-results-panel'); + + // Set scrolling properties immediately for results container with !important + this.resultsContainer.style.setProperty('max-height', '400px', 'important'); + this.resultsContainer.style.setProperty('overflow-y', 'scroll', 'important'); + this.resultsContainer.style.setProperty('overflow-x', 'hidden', 'important'); + this.resultsContainer.style.setProperty('min-height', '0', 'important'); + this.resultsContainer.style.setProperty('display', 'block', 'important'); + + // Set scrolling properties for suggestions container with !important + this.suggestionsContainer.style.setProperty('max-height', '200px', 'important'); + this.suggestionsContainer.style.setProperty('overflow-y', 'scroll', 'important'); + this.suggestionsContainer.style.setProperty('overflow-x', 'hidden', 'important'); + this.suggestionsContainer.style.setProperty('min-height', '0', 'important'); + this.suggestionsContainer.style.setProperty('display', 'block', 'important'); + + console.log('LocationSearch: Set scrolling properties on containers'); + + // Prevent map scroll events when scrolling inside the search containers + this.preventMapScrollOnContainers(); + + // No clear button or default panel in inline mode + this.clearButton = null; + this.defaultPanel = null; + } + + preventMapScrollOnContainers() { + // Prevent scroll events from bubbling to the map when scrolling inside search containers + const containers = [this.resultsContainer, this.suggestionsContainer, this.searchBar]; + + containers.forEach(container => { + if (container) { + // Prevent wheel events (scroll) from reaching the map + container.addEventListener('wheel', (e) => { + e.stopPropagation(); + }, { passive: false }); + + // Prevent touch scroll events from reaching the map + container.addEventListener('touchstart', (e) => { + e.stopPropagation(); + }, { passive: false }); + + container.addEventListener('touchmove', (e) => { + e.stopPropagation(); + }, { passive: false }); + + container.addEventListener('touchend', (e) => { + e.stopPropagation(); + }, { passive: false }); + + // Also prevent mousewheel for older browsers + container.addEventListener('mousewheel', (e) => { + e.stopPropagation(); + }, { passive: false }); + + // Prevent DOMMouseScroll for Firefox + container.addEventListener('DOMMouseScroll', (e) => { + e.stopPropagation(); + }, { passive: false }); + + console.log('LocationSearch: Added scroll prevention to container', container.id || 'search-bar'); + } + }); + } + + bindSearchEvents() { + // Toggle search bar visibility + this.toggleButton.addEventListener('click', (e) => { + console.log('Search button clicked!'); + e.preventDefault(); + e.stopPropagation(); + this.showSearchBar(); + }); + + // Close search bar + this.closeButton.addEventListener('click', () => { + this.hideSearchBar(); + }); + + // Search on Enter key + this.searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + if (this.suggestionsVisible && this.currentSuggestionIndex >= 0) { + this.selectSuggestion(this.currentSuggestionIndex); + } + } + }); + + // Clear search (no clear button in inline mode, handled by close button) + + // Handle real-time suggestions + this.searchInput.addEventListener('input', (e) => { + const query = e.target.value.trim(); + + if (query.length > 0) { + this.debouncedSuggestionSearch(query); + } else { + this.hideSuggestions(); + this.showDefaultState(); + } + }); + + // Handle keyboard navigation for suggestions + this.searchInput.addEventListener('keydown', (e) => { + if (this.suggestionsVisible) { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.navigateSuggestions(1); + break; + case 'ArrowUp': + e.preventDefault(); + this.navigateSuggestions(-1); + break; + case 'Escape': + this.hideSuggestions(); + this.showDefaultState(); + break; + } + } + }); + + // Close sidepanel on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.searchVisible) { + this.hideSearchBar(); + } + }); + + // Close search bar when clicking outside (but not on map interactions) + document.addEventListener('click', (e) => { + if (this.searchVisible && + !e.target.closest('#location-search-container') && + !e.target.closest('#location-search-toggle') && + !e.target.closest('.leaflet-container')) { // Don't close on map interactions + this.hideSearchBar(); + } + }); + + // Maintain search bar position during map movements + this.map.on('movestart zoomstart', () => { + if (this.searchVisible) { + // Store current button position before map movement + this.storedButtonPosition = this.toggleButton.getBoundingClientRect(); + } + }); + + // Reposition search bar after map movements to maintain relative position + this.map.on('moveend zoomend', () => { + if (this.searchVisible && this.storedButtonPosition) { + // Recalculate position based on new button position + this.repositionSearchBar(); + } + }); + } + + showLoading() { + // Hide other panels and show results with loading + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + this.resultsContainer.innerHTML = ` +
+
+
Searching for "${this.escapeHtml(this.currentSearchQuery)}"...
+
+ `; + } + + showError(message) { + // Hide other panels and show results with error + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + this.resultsContainer.innerHTML = ` +
+
⚠️
+
Search Failed
+
${this.escapeHtml(message)}
+
+ `; + } + + displaySearchResults(data) { + // Hide other panels and show results + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + if (!data.locations || data.locations.length === 0) { + this.resultsContainer.innerHTML = ` +
+
📍
+
No visits found
+
No visits found for "${this.escapeHtml(this.currentSearchQuery)}"
+
+ `; + return; + } + + this.searchResults = data.locations; + this.clearSearchMarkers(); + + let resultsHtml = ` +
+
Found ${data.total_locations} location(s)
+
for "${this.escapeHtml(this.currentSearchQuery)}"
+
+ `; + + data.locations.forEach((location, index) => { + resultsHtml += this.buildLocationResultHtml(location, index); + }); + + this.resultsContainer.innerHTML = resultsHtml; + + this.bindResultEvents(); + } + + buildLocationResultHtml(location, index) { + const firstVisit = location.visits[location.visits.length - 1]; + const lastVisit = location.visits[0]; + + // Group visits by year + const visitsByYear = this.groupVisitsByYear(location.visits); + + return ` +
+
+
${this.escapeHtml(location.place_name)}
+
${this.escapeHtml(location.address || '')}
+
+
${location.total_visits} visit(s)
+
+ first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)} +
+
+
+ + +
+ ${Object.entries(visitsByYear).map(([year, yearVisits]) => ` +
+
+ ${year} + ${yearVisits.length} visits + +
+ +
+ `).join('')} +
+
+ `; + } + + groupVisitsByYear(visits) { + const groups = {}; + visits.forEach(visit => { + const year = new Date(visit.date).getFullYear().toString(); + if (!groups[year]) { + groups[year] = []; + } + groups[year].push(visit); + }); + + // Sort years descending (most recent first) + const sortedGroups = {}; + Object.keys(groups) + .sort((a, b) => parseInt(b) - parseInt(a)) + .forEach(year => { + sortedGroups[year] = groups[year]; + }); + + return sortedGroups; + } + + formatDateShort(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + } + + bindResultEvents() { + // Bind click events to year toggles + const yearToggles = this.resultsContainer.querySelectorAll('.year-toggle'); + yearToggles.forEach(toggle => { + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + const locationIndex = parseInt(toggle.dataset.locationIndex); + const year = toggle.dataset.year; + this.toggleYear(locationIndex, year, toggle); + }); + }); + + // Bind click events to individual visits + const visitResults = this.resultsContainer.querySelectorAll('.visit-item'); + visitResults.forEach(visit => { + visit.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent triggering other clicks + const locationIndex = parseInt(visit.dataset.locationIndex); + const visitIndex = parseInt(visit.dataset.visitIndex); + this.focusOnVisit(this.searchResults[locationIndex], visitIndex); + }); + }); + } + + toggleYear(locationIndex, year, toggleElement) { + const yearVisitsContainer = document.getElementById(`year-${locationIndex}-${year}`); + const arrow = toggleElement.querySelector('.year-arrow'); + + if (yearVisitsContainer.classList.contains('hidden')) { + // Show visits + yearVisitsContainer.classList.remove('hidden'); + arrow.style.transform = 'rotate(90deg)'; + arrow.textContent = '▼'; + } else { + // Hide visits + yearVisitsContainer.classList.add('hidden'); + arrow.style.transform = 'rotate(0deg)'; + arrow.textContent = '▶'; + } + } + + + focusOnLocation(location) { + const [lat, lon] = location.coordinates; + this.map.setView([lat, lon], 16); + + // Flash the marker + const markers = this.searchMarkersLayer.getLayers(); + const targetMarker = markers.find(marker => { + const latLng = marker.getLatLng(); + return Math.abs(latLng.lat - lat) < 0.0001 && Math.abs(latLng.lng - lon) < 0.0001; + }); + + if (targetMarker) { + targetMarker.openPopup(); + } + + this.hideResults(); + } + + focusOnVisit(location, visitIndex) { + const visit = location.visits[visitIndex]; + if (!visit) return; + + // Navigate to the visit coordinates (more precise than location coordinates) + const [lat, lon] = visit.coordinates || location.coordinates; + this.map.setView([lat, lon], 18); // Higher zoom for individual visit + + // Parse the visit timestamp to create a time filter + const visitDate = new Date(visit.date); + const startTime = new Date(visitDate.getTime() - (2 * 60 * 60 * 1000)); // 2 hours before + const endTime = new Date(visitDate.getTime() + (2 * 60 * 60 * 1000)); // 2 hours after + + // Emit custom event for time filtering that other parts of the app can listen to + const timeFilterEvent = new CustomEvent('locationSearch:timeFilter', { + detail: { + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + visitDate: visit.date, + location: location.place_name, + coordinates: [lat, lon] + } + }); + + document.dispatchEvent(timeFilterEvent); + + // Create a special marker for the specific visit + this.addVisitMarker(lat, lon, visit, location); + + // DON'T hide results - keep sidebar open + // this.hideResults(); + } + + addVisitMarker(lat, lon, visit, location) { + // Remove existing visit marker if any + if (this.visitMarker) { + this.map.removeLayer(this.visitMarker); + } + + // Create a highlighted marker for the specific visit + this.visitMarker = L.circleMarker([lat, lon], { + radius: 12, + fillColor: '#22c55e', // Green color to distinguish from search results + color: '#ffffff', + weight: 3, + opacity: 1, + fillOpacity: 0.9 + }); + + const popupContent = ` +
+
${this.escapeHtml(location.place_name)}
+
${this.escapeHtml(location.address || '')}
+
+
Visit Details:
+
${this.formatDateTime(visit.date)}
+
Duration: ${visit.duration_estimate}
+
+
+ + +
+
+ `; + + this.visitMarker.bindPopup(popupContent, { + closeButton: true, + autoClose: false, // Don't auto-close when clicking elsewhere + closeOnEscapeKey: true, // Allow closing with Escape key + closeOnClick: false // Don't close when clicking on map + }); + + this.visitMarker.addTo(this.map); + this.visitMarker.openPopup(); + + // Add event listener to clean up when popup is closed + this.visitMarker.on('popupclose', () => { + if (this.visitMarker) { + this.map.removeLayer(this.visitMarker); + this.visitMarker = null; + } + }); + + // Store reference for manual cleanup if needed + this.currentVisitMarker = this.visitMarker; + } + + clearSearch() { + this.searchInput.value = ''; + this.hideResults(); + this.clearSearchMarkers(); + this.clearVisitMarker(); + this.currentSearchQuery = ''; + } + + clearVisitMarker() { + if (this.visitMarker) { + this.map.removeLayer(this.visitMarker); + this.visitMarker = null; + } + if (this.currentVisitMarker) { + this.map.removeLayer(this.currentVisitMarker); + this.currentVisitMarker = null; + } + + // Remove any visit notifications + const existingNotification = document.querySelector('.visit-navigation-notification'); + if (existingNotification) { + existingNotification.remove(); + } + } + + showSearchBar() { + console.log('showSearchBar called'); + + if (!this.searchBar) { + console.error('Search bar element not found!'); + return; + } + + // Position the search bar to the right of the search button at same height + const buttonRect = this.toggleButton.getBoundingClientRect(); + const mapRect = this.map.getContainer().getBoundingClientRect(); + + // Calculate position relative to the map container + const left = buttonRect.right - mapRect.left + 15; // 15px gap to the right of button + const top = buttonRect.top - mapRect.top; // Same height as button + + console.log('Positioning search bar at:', { left, top }); + + // Position search bar next to the button + this.searchBar.style.left = left + 'px'; + this.searchBar.style.top = top + 'px'; + this.searchBar.style.transform = 'none'; // Remove any transforms + this.searchBar.style.position = 'absolute'; // Position relative to map container + + // Show the search bar + this.searchBar.classList.remove('hidden'); + this.searchBar.style.setProperty('display', 'block', 'important'); + this.searchBar.style.visibility = 'visible'; + this.searchBar.style.opacity = '1'; + this.searchVisible = true; + + console.log('Search bar positioned next to button'); + + // Focus the search input for immediate typing + setTimeout(() => { + if (this.searchInput) { + this.searchInput.focus(); + } + }, 100); + } + + repositionSearchBar() { + if (!this.searchBar || !this.searchVisible) return; + + // Get current button position after map movement + const buttonRect = this.toggleButton.getBoundingClientRect(); + const mapRect = this.map.getContainer().getBoundingClientRect(); + + // Calculate new position + const left = buttonRect.right - mapRect.left + 15; + const top = buttonRect.top - mapRect.top; + + // Update search bar position + this.searchBar.style.left = left + 'px'; + this.searchBar.style.top = top + 'px'; + + console.log('Search bar repositioned after map movement'); + } + + hideSearchBar() { + this.searchBar.classList.add('hidden'); + this.searchBar.style.display = 'none'; + this.searchVisible = false; + this.clearSearch(); + this.hideResults(); + this.hideSuggestions(); + } + + showDefaultState() { + // No default panel in inline mode, just hide suggestions and results + this.hideSuggestions(); + this.hideResults(); + } + + clearSearchMarkers() { + // Note: No longer using search markers, but keeping method for compatibility + // Only clear visit markers if they exist + if (this.searchMarkersLayer) { + this.map.removeLayer(this.searchMarkersLayer); + this.searchMarkersLayer = null; + } + } + + hideResults() { + if (this.resultsPanel) { + this.resultsPanel.classList.add('hidden'); + } + } + + // Suggestion-related methods + debouncedSuggestionSearch(query) { + // Clear existing timeout + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + } + + // Set new timeout for debounced search + this.searchTimeout = setTimeout(() => { + this.performSuggestionSearch(query); + }, 300); // 300ms debounce delay + } + + async performSuggestionSearch(query) { + if (query.length < 2) { + this.hideSuggestions(); + return; + } + + // Show loading state for suggestions + this.showSuggestionsLoading(); + + try { + const response = await fetch(`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Suggestions failed: ${response.status}`); + } + + const data = await response.json(); + this.displaySuggestions(data.suggestions || []); + + } catch (error) { + console.error('Suggestion search error:', error); + this.hideSuggestions(); + } + } + + showSuggestionsLoading() { + // Hide other panels and show suggestions with loading + this.resultsPanel.classList.add('hidden'); + this.suggestionsPanel.classList.remove('hidden'); + + this.suggestionsContainer.innerHTML = ` +
+
+
Finding suggestions...
+
+ `; + } + + displaySuggestions(suggestions) { + if (!suggestions.length) { + this.hideSuggestions(); + return; + } + + // Hide other panels and show suggestions + this.resultsPanel.classList.add('hidden'); + this.suggestionsPanel.classList.remove('hidden'); + + // Build suggestions HTML + let suggestionsHtml = ''; + suggestions.forEach((suggestion, index) => { + const isActive = index === this.currentSuggestionIndex; + suggestionsHtml += ` +
+
${this.escapeHtml(suggestion.name)}
+
${this.escapeHtml(suggestion.address || '')}
+
+ `; + }); + + this.suggestionsContainer.innerHTML = suggestionsHtml; + this.suggestionsVisible = true; + this.suggestions = suggestions; + + // Bind click events to suggestions + this.bindSuggestionEvents(); + } + + bindSuggestionEvents() { + const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); + suggestionItems.forEach(item => { + item.addEventListener('click', (e) => { + const index = parseInt(e.currentTarget.dataset.suggestionIndex); + this.selectSuggestion(index); + }); + }); + } + + navigateSuggestions(direction) { + if (!this.suggestions || !this.suggestions.length) return; + + const maxIndex = this.suggestions.length - 1; + + if (direction > 0) { + // Arrow down + this.currentSuggestionIndex = this.currentSuggestionIndex < maxIndex + ? this.currentSuggestionIndex + 1 + : 0; + } else { + // Arrow up + this.currentSuggestionIndex = this.currentSuggestionIndex > 0 + ? this.currentSuggestionIndex - 1 + : maxIndex; + } + + this.highlightActiveSuggestion(); + } + + highlightActiveSuggestion() { + const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); + + suggestionItems.forEach((item, index) => { + if (index === this.currentSuggestionIndex) { + item.classList.add('bg-blue-50', 'text-blue-700'); + item.classList.remove('bg-gray-50'); + } else { + item.classList.remove('bg-blue-50', 'text-blue-700'); + item.classList.add('bg-gray-50'); + } + }); + } + + selectSuggestion(index) { + if (!this.suggestions || index < 0 || index >= this.suggestions.length) return; + + const suggestion = this.suggestions[index]; + this.searchInput.value = suggestion.name; + this.hideSuggestions(); + this.showSearchLoading(suggestion.name); + this.performCoordinateSearch(suggestion); // Use coordinate-based search for selected suggestion + } + + showSearchLoading(locationName) { + // Hide other panels and show loading for search results + this.suggestionsPanel.classList.add('hidden'); + this.resultsPanel.classList.remove('hidden'); + + this.resultsContainer.innerHTML = ` +
+
+
Searching visits to
+
${this.escapeHtml(locationName)}
+
+ `; + } + + async performCoordinateSearch(suggestion) { + this.currentSearchQuery = suggestion.name; + // Loading state already shown by showSearchLoading + + try { + const params = new URLSearchParams({ + lat: suggestion.coordinates[0], + lon: suggestion.coordinates[1], + name: suggestion.name, + address: suggestion.address || '' + }); + + const response = await fetch(`/api/v1/locations?${params}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Coordinate search failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + this.displaySearchResults(data); + + } catch (error) { + console.error('Coordinate search error:', error); + this.showError('Failed to search locations. Please try again.'); + } + } + + hideSuggestions() { + this.suggestionsPanel.classList.add('hidden'); + this.suggestionsVisible = false; + this.currentSuggestionIndex = -1; + this.suggestions = []; + + if (this.searchTimeout) { + clearTimeout(this.searchTimeout); + this.searchTimeout = null; + } + } + + createVisitAt(lat, lon, placeName, visitDate, durationEstimate) { + console.log(`Creating visit at ${lat}, ${lon} for ${placeName} at ${visitDate} (duration: ${durationEstimate})`); + + // Close the current visit popup + if (this.visitMarker) { + this.visitMarker.closePopup(); + } + + // Calculate start and end times from the original visit + const { startTime, endTime } = this.calculateVisitTimes(visitDate, durationEstimate); + + this.showBasicVisitForm(lat, lon, placeName, startTime, endTime); + } + + showBasicVisitForm(lat, lon, placeName, presetStartTime, presetEndTime) { + // Close any existing visit form popups first + const existingPopups = document.querySelectorAll('.basic-visit-form-popup'); + existingPopups.forEach(popup => { + const leafletPopup = popup.closest('.leaflet-popup'); + if (leafletPopup) { + const closeButton = leafletPopup.querySelector('.leaflet-popup-close-button'); + if (closeButton) closeButton.click(); + } + }); + + // Use preset times if available, otherwise use current time defaults + let startTime, endTime; + + if (presetStartTime && presetEndTime) { + startTime = presetStartTime; + endTime = presetEndTime; + console.log('Using preset times:', { startTime, endTime }); + } else { + console.log('No preset times provided, using defaults'); + // Get current date/time for default values + const now = new Date(); + const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000)); + + // Format dates for datetime-local input + const formatDateTime = (date) => { + return date.toISOString().slice(0, 16); + }; + + startTime = formatDateTime(now); + endTime = formatDateTime(oneHourLater); + } + + // Create form HTML + const formHTML = ` +
+

Add New Visit

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+ + +
+
+
+ `; + + // Create popup at the location + const basicVisitPopup = L.popup({ + closeOnClick: false, + autoClose: false, + maxWidth: 300, + className: 'basic-visit-form-popup' + }) + .setLatLng([lat, lon]) + .setContent(formHTML) + .openOn(this.map); + + // Add event listeners after the popup is added to DOM + setTimeout(() => { + const form = document.getElementById('basic-add-visit-form'); + const cancelButton = document.getElementById('basic-cancel-visit'); + const nameInput = document.getElementById('basic-visit-name'); + + if (form) { + form.addEventListener('submit', (e) => this.handleBasicFormSubmit(e, basicVisitPopup)); + } + + if (cancelButton) { + cancelButton.addEventListener('click', () => { + this.map.closePopup(basicVisitPopup); + }); + } + + // Focus and select the name input + if (nameInput) { + nameInput.focus(); + nameInput.select(); + } + }, 100); + } + + async handleBasicFormSubmit(event, popup) { + event.preventDefault(); + + const form = event.target; + const formData = new FormData(form); + + // Get form values + const visitData = { + visit: { + name: formData.get('name'), + started_at: formData.get('started_at'), + ended_at: formData.get('ended_at'), + latitude: formData.get('latitude'), + longitude: formData.get('longitude') + } + }; + + // Validate that end time is after start time + const startTime = new Date(visitData.visit.started_at); + const endTime = new Date(visitData.visit.ended_at); + + if (endTime <= startTime) { + alert('End time must be after start time'); + return; + } + + // Disable form while submitting + const submitButton = form.querySelector('button[type="submit"]'); + const originalText = submitButton.textContent; + submitButton.disabled = true; + submitButton.textContent = 'Creating...'; + + try { + const response = await fetch(`/api/v1/visits`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${this.apiKey}` + }, + body: JSON.stringify(visitData) + }); + + const data = await response.json(); + + if (response.ok) { + alert(`Visit "${visitData.visit.name}" created successfully!`); + this.map.closePopup(popup); + + // Try to refresh visits layer if available + this.refreshVisitsIfAvailable(); + } else { + const errorMessage = data.error || data.message || 'Failed to create visit'; + alert(errorMessage); + } + } catch (error) { + console.error('Error creating visit:', error); + alert('Network error: Failed to create visit'); + } finally { + // Re-enable form + submitButton.disabled = false; + submitButton.textContent = originalText; + } + } + + refreshVisitsIfAvailable() { + // Try to refresh visits layer if available + const mapsController = document.querySelector('[data-controller*="maps"]'); + if (mapsController) { + const stimulusApp = window.Stimulus || window.stimulus; + if (stimulusApp) { + const controller = stimulusApp.getControllerForElementAndIdentifier(mapsController, 'maps'); + if (controller && controller.visitsManager && controller.visitsManager.fetchAndDisplayVisits) { + console.log('Refreshing visits layer after creating visit'); + controller.visitsManager.fetchAndDisplayVisits(); + } + } + } + } + + calculateVisitTimes(visitDate, durationEstimate) { + if (!visitDate) { + return { startTime: null, endTime: null }; + } + + try { + // Parse the visit date (e.g., "2022-12-27T18:01:00.000Z") + const visitDateTime = new Date(visitDate); + + // Parse duration estimate (e.g., "~15m", "~1h 44m", "~2h 30m") + let durationMinutes = 15; // Default to 15 minutes if parsing fails + + if (durationEstimate) { + const durationStr = durationEstimate.replace('~', '').trim(); + + // Match patterns like "15m", "1h 44m", "2h", etc. + const hoursMatch = durationStr.match(/(\d+)h/); + const minutesMatch = durationStr.match(/(\d+)m/); + + let hours = 0; + let minutes = 0; + + if (hoursMatch) { + hours = parseInt(hoursMatch[1]); + } + if (minutesMatch) { + minutes = parseInt(minutesMatch[1]); + } + + durationMinutes = (hours * 60) + minutes; + + // If no matches found, try to parse as pure minutes + if (durationMinutes === 0) { + const pureMinutes = parseInt(durationStr); + if (!isNaN(pureMinutes)) { + durationMinutes = pureMinutes; + } + } + } + + // Calculate start time (visit time) and end time (visit time + duration) + const startTime = visitDateTime.toISOString().slice(0, 16); // Format for datetime-local + const endDateTime = new Date(visitDateTime.getTime() + (durationMinutes * 60 * 1000)); + const endTime = endDateTime.toISOString().slice(0, 16); + + console.log(`Calculated visit times: ${startTime} to ${endTime} (duration: ${durationMinutes} minutes)`); + + return { startTime, endTime }; + } catch (error) { + console.error('Error calculating visit times:', error); + return { startTime: null, endTime: null }; + } + } + + // Utility methods + escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text ? text.replace(/[&<>"']/g, m => map[m]) : ''; + } + + formatDate(dateString) { + return new Date(dateString).toLocaleDateString(); + } + + formatDateTime(dateString) { + return new Date(dateString).toLocaleDateString() + ' ' + + new Date(dateString).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + } +} + +export { LocationSearch }; diff --git a/app/javascript/maps/theme_utils.js b/app/javascript/maps/theme_utils.js new file mode 100644 index 00000000..e0a771af --- /dev/null +++ b/app/javascript/maps/theme_utils.js @@ -0,0 +1,79 @@ +// Theme utility functions for map controls and buttons + +/** + * Get theme-aware styles for map controls based on user theme + * @param {string} userTheme - 'light' or 'dark' + * @returns {Object} Object containing CSS properties for the theme + */ +export function getThemeStyles(userTheme) { + if (userTheme === 'light') { + return { + backgroundColor: '#ffffff', + color: '#000000', + borderColor: '#e5e7eb', + shadowColor: 'rgba(0, 0, 0, 0.1)' + }; + } else { + return { + backgroundColor: '#374151', + color: '#ffffff', + borderColor: '#4b5563', + shadowColor: 'rgba(0, 0, 0, 0.3)' + }; + } +} + +/** + * Apply theme-aware styles to a control element + * @param {HTMLElement} element - DOM element to style + * @param {string} userTheme - 'light' or 'dark' + * @param {Object} additionalStyles - Optional additional CSS properties + */ +export function applyThemeToControl(element, userTheme, additionalStyles = {}) { + const themeStyles = getThemeStyles(userTheme); + + // Apply base theme styles + element.style.backgroundColor = themeStyles.backgroundColor; + element.style.color = themeStyles.color; + element.style.border = `1px solid ${themeStyles.borderColor}`; + element.style.boxShadow = `0 1px 4px ${themeStyles.shadowColor}`; + + // Apply any additional styles + Object.assign(element.style, additionalStyles); +} + +/** + * Apply theme-aware styles to a button element + * @param {HTMLElement} button - Button element to style + * @param {string} userTheme - 'light' or 'dark' + */ +export function applyThemeToButton(button, userTheme) { + applyThemeToControl(button, userTheme, { + border: 'none', + cursor: 'pointer' + }); + + // Add hover effects + const themeStyles = getThemeStyles(userTheme); + const hoverBg = userTheme === 'light' ? '#f3f4f6' : '#4b5563'; + + button.addEventListener('mouseenter', () => { + button.style.backgroundColor = hoverBg; + }); + + button.addEventListener('mouseleave', () => { + button.style.backgroundColor = themeStyles.backgroundColor; + }); +} + +/** + * Apply theme-aware styles to a panel/container element + * @param {HTMLElement} panel - Panel element to style + * @param {string} userTheme - 'light' or 'dark' + */ +export function applyThemeToPanel(panel, userTheme) { + applyThemeToControl(panel, userTheme, { + borderRadius: '4px' + }); +} + diff --git a/app/javascript/maps/visits.js b/app/javascript/maps/visits.js index 0ceeb415..4a1bdf35 100644 --- a/app/javascript/maps/visits.js +++ b/app/javascript/maps/visits.js @@ -1,23 +1,28 @@ import L from "leaflet"; import { showFlashMessage } from "./helpers"; +import { applyThemeToButton } from "./theme_utils"; /** * Manages visits functionality including displaying, fetching, and interacting with visits */ export class VisitsManager { - constructor(map, apiKey) { + constructor(map, apiKey, userTheme = 'dark') { this.map = map; this.apiKey = apiKey; + this.userTheme = userTheme; // Create custom panes for different visit types - if (!map.getPane('confirmedVisitsPane')) { - map.createPane('confirmedVisitsPane'); - map.getPane('confirmedVisitsPane').style.zIndex = 450; // Above default overlay pane (400) - } - + // Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700 if (!map.getPane('suggestedVisitsPane')) { map.createPane('suggestedVisitsPane'); - map.getPane('suggestedVisitsPane').style.zIndex = 460; // Below confirmed visits but above base layers + map.getPane('suggestedVisitsPane').style.zIndex = 610; // Above markerPane (600), below tooltipPane (650) + map.getPane('suggestedVisitsPane').style.pointerEvents = 'auto'; // Ensure interactions work + } + + if (!map.getPane('confirmedVisitsPane')) { + map.createPane('confirmedVisitsPane'); + map.getPane('confirmedVisitsPane').style.zIndex = 620; // Above suggested visits + map.getPane('confirmedVisitsPane').style.pointerEvents = 'auto'; // Ensure interactions work } this.visitCircles = L.layerGroup(); @@ -67,12 +72,10 @@ export class VisitsManager { onAdd: (map) => { const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button'); button.innerHTML = '⬅️'; // Left arrow icon + // Style the button with theme-aware styling + applyThemeToButton(button, this.userTheme); button.style.width = '48px'; button.style.height = '48px'; - button.style.border = 'none'; - button.style.cursor = 'pointer'; - button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; - button.style.backgroundColor = 'white'; button.style.borderRadius = '4px'; button.style.padding = '0'; button.style.lineHeight = '48px'; @@ -104,12 +107,10 @@ export class VisitsManager { button.innerHTML = '⚓️'; button.title = 'Select Area'; button.id = 'selection-tool-button'; + // Style the button with theme-aware styling + applyThemeToButton(button, this.userTheme); button.style.width = '48px'; button.style.height = '48px'; - button.style.border = 'none'; - button.style.cursor = 'pointer'; - button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; - button.style.backgroundColor = 'white'; button.style.borderRadius = '4px'; button.style.padding = '0'; button.style.lineHeight = '48px'; @@ -1326,44 +1327,60 @@ export class VisitsManager { // Create popup content with form and dropdown const defaultName = visit.name; const popupContent = ` -
-
-
- ${dateTimeDisplay.trim()} -
-
- - Duration: ${durationText}, - - - status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)} - - ${visit.place.latitude}, ${visit.place.longitude} -
+
+

${dateTimeDisplay.trim()}

+ +
+
Duration: ${durationText}
+
Status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
+
${visit.place.latitude}, ${visit.place.longitude}
-
+ +
+
-
- + ${possiblePlaces.length > 0 ? possiblePlaces.map(place => ` - `).join('')} + `).join('') : ` + + `}
-
- + +
+ ${visit.status !== 'confirmed' ? ` - - - ` : ''} + + + ` : '
'}
+ +
`; @@ -1374,8 +1391,9 @@ export class VisitsManager { closeOnClick: true, autoClose: true, closeOnEscapeKey: true, - maxWidth: 450, // Set maximum width - minWidth: 300 // Set minimum width + maxWidth: 420, // Set maximum width + minWidth: 320, // Set minimum width + className: 'visit-popup' // Add custom class for additional styling }) .setLatLng([visit.place.latitude, visit.place.longitude]) .setContent(popupContent); @@ -1407,6 +1425,12 @@ export class VisitsManager { const newName = event.target.querySelector('input').value; const selectedPlaceId = event.target.querySelector('select[name="place"]').value; + // Validate that we have a valid place_id + if (!selectedPlaceId || selectedPlaceId === '') { + showFlashMessage('error', 'Please select a valid location'); + return; + } + // Get the selected place name from the dropdown const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`); const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : ''; @@ -1473,9 +1497,11 @@ export class VisitsManager { // Add event listeners for confirm and decline buttons const confirmBtn = form.querySelector('.confirm-visit'); const declineBtn = form.querySelector('.decline-visit'); + const deleteBtn = form.querySelector('.delete-visit'); confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed')); declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined')); + deleteBtn?.addEventListener('click', (event) => this.handleDeleteVisit(event, visit.id)); } } @@ -1517,6 +1543,51 @@ export class VisitsManager { } } + /** + * Handles deletion of a visit with confirmation + * @param {Event} event - The click event + * @param {string} visitId - The visit ID to delete + */ + async handleDeleteVisit(event, visitId) { + event.preventDefault(); + event.stopPropagation(); + + // Show confirmation dialog + const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.'); + + if (!confirmDelete) { + return; + } + + try { + const response = await fetch(`/api/v1/visits/${visitId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + } + }); + + if (response.ok) { + // Close the popup + if (this.currentPopup) { + this.map.closePopup(this.currentPopup); + this.currentPopup = null; + } + + // Refresh the visits list + this.fetchAndDisplayVisits(); + showFlashMessage('notice', 'Visit deleted successfully'); + } else { + const errorData = await response.json(); + const errorMessage = errorData.error || 'Failed to delete visit'; + showFlashMessage('error', errorMessage); + } + } catch (error) { + console.error('Error deleting visit:', error); + showFlashMessage('error', 'Failed to delete visit'); + } + } + /** * Truncates text to a specified length and adds ellipsis if needed * @param {string} text - The text to truncate diff --git a/app/javascript/posthog.js b/app/javascript/posthog.js new file mode 100644 index 00000000..a6e04171 --- /dev/null +++ b/app/javascript/posthog.js @@ -0,0 +1,6 @@ +!function(t,e){var o,n,p,r;e.__SV||(window.posthog && window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init Ce Ds js Te Os As capture Ye calculateEventProperties Us register register_once register_for_session unregister unregister_for_session Hs getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey displaySurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty qs Ns createPersonProfile Bs Cs Ws opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing get_explicit_consent_status is_capturing clear_opt_in_out_capturing Ls debug L zs getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n e + ExceptionReporter.call(e, "Failed to resolve boundaries for user #{user_id}") + + mark_session_failed(e.message) + end + + private + + attr_reader :user, :session_manager + + def session_exists_and_ready? + return false unless session_manager.session_exists? + + unless session_manager.all_chunks_completed? + reschedule_boundary_resolution + + return false + end + + true + end + + def resolve_boundary_tracks + boundary_detector = Tracks::BoundaryDetector.new(user) + boundary_detector.resolve_cross_chunk_tracks + end + + def finalize_session(boundary_tracks_resolved) + session_data = session_manager.get_session_data + total_tracks = session_data['tracks_created'] + boundary_tracks_resolved + + session_manager.mark_completed + end + + def reschedule_boundary_resolution + # Reschedule with exponential backoff (max 5 minutes) + delay = [30.seconds, 1.minute, 2.minutes, 5.minutes].sample + + self.class.set(wait: delay).perform_later(user.id, session_manager.session_id) + end + + def mark_session_failed(error_message) + session_manager.mark_failed(error_message) + end +end diff --git a/app/jobs/tracks/cleanup_job.rb b/app/jobs/tracks/cleanup_job.rb deleted file mode 100644 index 82eae62d..00000000 --- a/app/jobs/tracks/cleanup_job.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -# Lightweight cleanup job that runs weekly to catch any missed track generation. -# -# This provides a safety net while avoiding the overhead of daily bulk processing. -class Tracks::CleanupJob < ApplicationJob - queue_as :tracks - sidekiq_options retry: false - - def perform(older_than: 1.day.ago) - users_with_old_untracked_points(older_than).find_each do |user| - Rails.logger.info "Processing missed tracks for user #{user.id}" - - # Process only the old untracked points - Tracks::Generator.new( - user, - end_at: older_than, - mode: :incremental - ).call - end - end - - private - - def users_with_old_untracked_points(older_than) - User.active.joins(:tracked_points) - .where(tracked_points: { track_id: nil, timestamp: ..older_than.to_i }) - .having('COUNT(tracked_points.id) >= 2') # Only users with enough points for tracks - .group(:id) - end -end diff --git a/app/jobs/tracks/create_job.rb b/app/jobs/tracks/create_job.rb deleted file mode 100644 index a65805c4..00000000 --- a/app/jobs/tracks/create_job.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -class Tracks::CreateJob < ApplicationJob - queue_as :tracks - - def perform(user_id, start_at: nil, end_at: nil, mode: :daily) - user = User.find(user_id) - - tracks_created = Tracks::Generator.new(user, start_at:, end_at:, mode:).call - - create_success_notification(user, tracks_created) - rescue StandardError => e - ExceptionReporter.call(e, 'Failed to create tracks for user') - - create_error_notification(user, e) - end - - private - - def create_success_notification(user, tracks_created) - Notifications::Create.new( - user: user, - kind: :info, - title: 'Tracks Generated', - content: "Created #{tracks_created} tracks from your location data. Check your tracks section to view them." - ).call - end - - def create_error_notification(user, error) - return unless DawarichSettings.self_hosted? - - Notifications::Create.new( - user: user, - kind: :error, - title: 'Track Generation Failed', - content: "Failed to generate tracks from your location data: #{error.message}" - ).call - end -end diff --git a/app/jobs/tracks/daily_generation_job.rb b/app/jobs/tracks/daily_generation_job.rb new file mode 100644 index 00000000..ba149f8a --- /dev/null +++ b/app/jobs/tracks/daily_generation_job.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# Daily Track Generation Job +# +# Automatically processes new location points for all active/trial users on a regular schedule. +# This job runs periodically (recommended: every 2-4 hours) to generate tracks from newly +# received location data. +# +# Process: +# 1. Iterates through all active or trial users +# 2. For each user, finds the timestamp of their last track's end_at +# 3. Checks if there are new points since that timestamp +# 4. If new points exist, triggers parallel track generation using the existing system +# 5. Uses the parallel generator with 'daily' mode for optimal performance +# +# The job leverages the existing parallel track generation infrastructure, +# ensuring consistency with bulk operations while providing automatic daily processing. + +class Tracks::DailyGenerationJob < ApplicationJob + queue_as :tracks + + def perform + User.active_or_trial.find_each do |user| + next if user.points_count.zero? + + process_user_daily_tracks(user) + rescue StandardError => e + ExceptionReporter.call(e, "Failed to process daily tracks for user #{user.id}") + end + end + + private + + def process_user_daily_tracks(user) + start_timestamp = start_timestamp(user) + + return unless user.points.where('timestamp >= ?', start_timestamp).exists? + + Tracks::ParallelGeneratorJob.perform_later( + user.id, + start_at: start_timestamp, + end_at: Time.current.to_i, + mode: 'daily' + ) + end + + def start_timestamp(user) + last_end = user.tracks.maximum(:end_at)&.to_i + return last_end + 1 if last_end + + user.points.minimum(:timestamp) || 1.week.ago.to_i + end +end diff --git a/app/jobs/tracks/incremental_check_job.rb b/app/jobs/tracks/incremental_check_job.rb deleted file mode 100644 index 738246d6..00000000 --- a/app/jobs/tracks/incremental_check_job.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class Tracks::IncrementalCheckJob < ApplicationJob - queue_as :tracks - - def perform(user_id, point_id) - user = User.find(user_id) - point = Point.find(point_id) - - Tracks::IncrementalProcessor.new(user, point).call - end -end diff --git a/app/jobs/tracks/parallel_generator_job.rb b/app/jobs/tracks/parallel_generator_job.rb new file mode 100644 index 00000000..cc22afed --- /dev/null +++ b/app/jobs/tracks/parallel_generator_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Entry point job for parallel track generation +# Coordinates the entire parallel processing workflow +class Tracks::ParallelGeneratorJob < ApplicationJob + queue_as :tracks + + def perform(user_id, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day) + user = User.find(user_id) + + Tracks::ParallelGenerator.new( + user, + start_at: start_at, + end_at: end_at, + mode: mode, + chunk_size: chunk_size + ).call + rescue StandardError => e + ExceptionReporter.call(e, 'Failed to start parallel track generation') + end +end diff --git a/app/jobs/tracks/time_chunk_processor_job.rb b/app/jobs/tracks/time_chunk_processor_job.rb new file mode 100644 index 00000000..0428bdb0 --- /dev/null +++ b/app/jobs/tracks/time_chunk_processor_job.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +# Processes individual time chunks in parallel for track generation +# Each job handles one time chunk independently using in-memory segmentation +class Tracks::TimeChunkProcessorJob < ApplicationJob + include Tracks::Segmentation + include Tracks::TrackBuilder + + queue_as :tracks + + def perform(user_id, session_id, chunk_data) + @user = User.find(user_id) + @session_manager = Tracks::SessionManager.new(user_id, session_id) + @chunk_data = chunk_data + + return unless session_exists? + + tracks_created = process_chunk + update_session_progress(tracks_created) + rescue StandardError => e + ExceptionReporter.call(e, "Failed to process time chunk for user #{user_id}") + + mark_session_failed(e.message) + end + + private + + attr_reader :user, :session_manager, :chunk_data + + def session_exists? + unless session_manager.session_exists? + Rails.logger.warn "Session #{session_manager.session_id} not found for user #{user.id}, skipping chunk" + return false + end + true + end + + def process_chunk + # Load points for the buffer range + points = load_chunk_points + return 0 if points.empty? + + # Segment points using Geocoder-based logic + segments = segment_chunk_points(points) + return 0 if segments.empty? + + # Create tracks from segments + tracks_created = 0 + segments.each do |segment_points| + tracks_created += 1 if create_track_from_points_array(segment_points) + end + + tracks_created + end + + def load_chunk_points + user.points + .where(timestamp: chunk_data[:buffer_start_timestamp]..chunk_data[:buffer_end_timestamp]) + .order(:timestamp) + end + + def segment_chunk_points(points) + # Convert relation to array for in-memory processing + points_array = points.to_a + + # Use Geocoder-based segmentation + segments = split_points_into_segments_geocoder(points_array) + + # Filter segments to only include those that overlap with the actual chunk range + # (not just the buffer range) + segments.select do |segment| + segment_overlaps_chunk_range?(segment) + end + end + + def segment_overlaps_chunk_range?(segment) + return false if segment.empty? + + segment_start = segment.first.timestamp + segment_end = segment.last.timestamp + chunk_start = chunk_data[:start_timestamp] + chunk_end = chunk_data[:end_timestamp] + + # Check if segment overlaps with the actual chunk range (not buffer) + segment_start <= chunk_end && segment_end >= chunk_start + end + + def create_track_from_points_array(points) + return nil if points.size < 2 + + begin + # Calculate distance using Geocoder with validation + distance = Point.calculate_distance_for_array_geocoder(points, :km) + + # Additional validation for the distance result + if !distance.finite? || distance < 0 + Rails.logger.error "Invalid distance calculated (#{distance}) for #{points.size} points in chunk #{chunk_data[:chunk_id]}" + Rails.logger.debug "Point coordinates: #{points.map { |p| [p.latitude, p.longitude] }.inspect}" + return nil + end + + track = create_track_from_points(points, distance * 1000) # Convert km to meters + + if track + Rails.logger.debug "Created track #{track.id} with #{points.size} points (#{distance.round(2)} km)" + else + Rails.logger.warn "Failed to create track from #{points.size} points with distance #{distance.round(2)} km" + end + + track + rescue StandardError => e + nil + end + end + + def update_session_progress(tracks_created) + session_manager.increment_completed_chunks + session_manager.increment_tracks_created(tracks_created) if tracks_created > 0 + end + + def mark_session_failed(error_message) + session_manager.mark_failed(error_message) + end + + # Required by Tracks::Segmentation module + def distance_threshold_meters + @distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i + end + + def time_threshold_minutes + @time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i + end +end diff --git a/app/jobs/users/mailer_sending_job.rb b/app/jobs/users/mailer_sending_job.rb index bbce993f..742db9eb 100644 --- a/app/jobs/users/mailer_sending_job.rb +++ b/app/jobs/users/mailer_sending_job.rb @@ -6,19 +6,46 @@ class Users::MailerSendingJob < ApplicationJob def perform(user_id, email_type, **options) user = User.find(user_id) - if trial_related_email?(email_type) && user.active? - Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed" + if should_skip_email?(user, email_type) + ExceptionReporter.call( + 'Users::MailerSendingJob', + "Skipping #{email_type} email for user ID #{user_id} - #{skip_reason(user, email_type)}" + ) + return end params = { user: user }.merge(options) UsersMailer.with(params).public_send(email_type).deliver_later + rescue ActiveRecord::RecordNotFound + ExceptionReporter.call( + 'Users::MailerSendingJob', + "User with ID #{user_id} not found. Skipping #{email_type} email." + ) end private - def trial_related_email?(email_type) - %w[trial_expires_soon trial_expired].include?(email_type.to_s) + def should_skip_email?(user, email_type) + case email_type.to_s + when 'trial_expires_soon', 'trial_expired' + user.active? + when 'post_trial_reminder_early', 'post_trial_reminder_late' + user.active? || !user.trial? + else + false + end + end + + def skip_reason(user, email_type) + case email_type.to_s + when 'trial_expires_soon', 'trial_expired' + 'user is already subscribed' + when 'post_trial_reminder_early', 'post_trial_reminder_late' + user.active? ? 'user is subscribed' : 'user is not in trial state' + else + 'unknown reason' + end end end diff --git a/app/mailers/family_mailer.rb b/app/mailers/family_mailer.rb new file mode 100644 index 00000000..b0c2673b --- /dev/null +++ b/app/mailers/family_mailer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class FamilyMailer < ApplicationMailer + def invitation(invitation) + @invitation = invitation + @family = invitation.family + @invited_by = invitation.invited_by + @accept_url = family_invitation_url(@invitation.token) + + mail( + to: @invitation.email, + subject: "🎉 You've been invited to join #{@family.name} on Dawarich!" + ) + end + + def member_joined(family, user) + @family = family + @user = user + + mail( + to: @family.owner.email, + subject: "👪 #{@user.name} has joined your family #{@family.name} on Dawarich!" + ) + end +end diff --git a/app/mailers/users_mailer.rb b/app/mailers/users_mailer.rb index c7293a75..95afd3ea 100644 --- a/app/mailers/users_mailer.rb +++ b/app/mailers/users_mailer.rb @@ -2,26 +2,44 @@ class UsersMailer < ApplicationMailer def welcome + # Sent after user signs up @user = params[:user] mail(to: @user.email, subject: 'Welcome to Dawarich!') end def explore_features + # Sent 2 days after user signs up @user = params[:user] mail(to: @user.email, subject: 'Explore Dawarich features!') end def trial_expires_soon + # Sent 2 days before trial expires @user = params[:user] mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days') end def trial_expired + # Sent when trial expires @user = params[:user] mail(to: @user.email, subject: '💔 Your Dawarich trial expired') end + + def post_trial_reminder_early + # Sent 2 days after trial expires + @user = params[:user] + + mail(to: @user.email, subject: '🚀 Still interested in Dawarich? Subscribe now!') + end + + def post_trial_reminder_late + # Sent 7 days after trial expires + @user = params[:user] + + mail(to: @user.email, subject: '📍 Your location data is waiting - Subscribe to Dawarich') + end end diff --git a/app/models/concerns/distanceable.rb b/app/models/concerns/distanceable.rb index 532cfa7f..e47b3971 100644 --- a/app/models/concerns/distanceable.rb +++ b/app/models/concerns/distanceable.rb @@ -12,6 +12,74 @@ module Distanceable end end + # In-memory distance calculation using Geocoder (no SQL dependency) + def calculate_distance_for_array_geocoder(points, unit = :km) + unless ::DISTANCE_UNITS.key?(unit.to_sym) + raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}" + end + + return 0 if points.length < 2 + + total_meters = points.each_cons(2).sum do |p1, p2| + # Extract coordinates from lonlat (source of truth) + begin + # Check if lonlat exists and is valid + if p1.lonlat.nil? || p2.lonlat.nil? + Rails.logger.warn "Skipping distance calculation for points with nil lonlat: p1(#{p1.id}), p2(#{p2.id})" + next 0 + end + + lat1, lon1 = p1.lat, p1.lon + lat2, lon2 = p2.lat, p2.lon + + # Check for nil coordinates extracted from lonlat + if lat1.nil? || lon1.nil? || lat2.nil? || lon2.nil? + Rails.logger.warn "Skipping distance calculation for points with nil extracted coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})" + next 0 + end + + # Check for NaN or infinite coordinates + if [lat1, lon1, lat2, lon2].any? { |coord| !coord.finite? } + Rails.logger.warn "Skipping distance calculation for points with invalid coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})" + next 0 + end + + # Check for valid latitude/longitude ranges + if lat1.abs > 90 || lat2.abs > 90 || lon1.abs > 180 || lon2.abs > 180 + Rails.logger.warn "Skipping distance calculation for points with out-of-range coordinates: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})" + next 0 + end + + distance_km = Geocoder::Calculations.distance_between( + [lat1, lon1], + [lat2, lon2], + units: :km + ) + + # Check if Geocoder returned NaN or infinite value + if !distance_km.finite? + Rails.logger.warn "Geocoder returned invalid distance (#{distance_km}) for points: p1(#{p1.id}: #{lat1}, #{lon1}), p2(#{p2.id}: #{lat2}, #{lon2})" + next 0 + end + + distance_km * 1000 # Convert km to meters + rescue StandardError => e + Rails.logger.error "Error extracting coordinates from lonlat for points #{p1.id}, #{p2.id}: #{e.message}" + next 0 + end + end + + result = total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym] + + # Final validation of result + if !result.finite? + Rails.logger.error "Final distance calculation resulted in invalid value (#{result}) for #{points.length} points" + return 0 + end + + result + end + private def calculate_distance_for_relation(unit) @@ -85,7 +153,7 @@ module Distanceable FROM point_pairs ORDER BY pair_id SQL - + results = connection.select_all(sql_with_params) # Return array of distances in meters @@ -113,6 +181,79 @@ module Distanceable distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym] end + # In-memory distance calculation using Geocoder (no SQL dependency) + def distance_to_geocoder(other_point, unit = :km) + unless ::DISTANCE_UNITS.key?(unit.to_sym) + raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}" + end + + begin + # Extract coordinates from lonlat (source of truth) for current point + if lonlat.nil? + Rails.logger.warn "Cannot calculate distance: current point has nil lonlat" + return 0 + end + + current_lat, current_lon = lat, lon + + other_lat, other_lon = case other_point + when Array + [other_point[0], other_point[1]] + else + # For other Point objects, extract from their lonlat too + if other_point.respond_to?(:lonlat) && other_point.lonlat.nil? + Rails.logger.warn "Cannot calculate distance: other point has nil lonlat" + return 0 + end + [other_point.lat, other_point.lon] + end + + # Check for nil coordinates extracted from lonlat + if current_lat.nil? || current_lon.nil? || other_lat.nil? || other_lon.nil? + Rails.logger.warn "Cannot calculate distance: nil coordinates detected - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})" + return 0 + end + + # Check for NaN or infinite coordinates + coords = [current_lat, current_lon, other_lat, other_lon] + if coords.any? { |coord| !coord.finite? } + Rails.logger.warn "Cannot calculate distance: invalid coordinates detected - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})" + return 0 + end + + # Check for valid latitude/longitude ranges + if current_lat.abs > 90 || other_lat.abs > 90 || current_lon.abs > 180 || other_lon.abs > 180 + Rails.logger.warn "Cannot calculate distance: out-of-range coordinates - current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})" + return 0 + end + + distance_km = Geocoder::Calculations.distance_between( + [current_lat, current_lon], + [other_lat, other_lon], + units: :km + ) + + # Check if Geocoder returned valid distance + if !distance_km.finite? + Rails.logger.warn "Geocoder returned invalid distance (#{distance_km}) for points: current(#{current_lat}, #{current_lon}), other(#{other_lat}, #{other_lon})" + return 0 + end + + result = (distance_km * 1000).to_f / ::DISTANCE_UNITS[unit.to_sym] + + # Final validation + if !result.finite? + Rails.logger.error "Final distance calculation resulted in invalid value (#{result})" + return 0 + end + + result + rescue StandardError => e + Rails.logger.error "Error calculating distance from lonlat: #{e.message}" + 0 + end + end + private def extract_point(point) diff --git a/app/models/concerns/user_family.rb b/app/models/concerns/user_family.rb new file mode 100644 index 00000000..53119792 --- /dev/null +++ b/app/models/concerns/user_family.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module UserFamily + extend ActiveSupport::Concern + + included do + has_one :family_membership, dependent: :destroy, class_name: 'Family::Membership' + has_one :family, through: :family_membership + has_one :created_family, class_name: 'Family', foreign_key: 'creator_id', inverse_of: :creator, dependent: :destroy + has_many :sent_family_invitations, class_name: 'Family::Invitation', foreign_key: 'invited_by_id', + inverse_of: :invited_by, dependent: :destroy + + before_destroy :check_family_ownership + end + + def in_family? + family_membership.present? + end + + def family_owner? + family_membership&.owner? == true + end + + def can_delete_account? + return true unless family_owner? + return true unless family + + family.members.count <= 1 + end + + def family_sharing_enabled? + return false unless in_family? + + sharing_settings = settings.dig('family', 'location_sharing') + return false unless sharing_settings.is_a?(Hash) + return false unless sharing_settings['enabled'] == true + + expires_at = sharing_settings['expires_at'] + expires_at.blank? || Time.parse(expires_at).future? + end + + def update_family_location_sharing!(enabled, duration: nil) + return false unless in_family? + + current_settings = settings || {} + current_settings['family'] ||= {} + + if enabled + sharing_config = { 'enabled' => true } + + if duration.present? + expiration_time = case duration + when '1h' then 1.hour.from_now + when '6h' then 6.hours.from_now + when '12h' then 12.hours.from_now + when '24h' then 24.hours.from_now + when 'permanent' then nil + else duration.to_i.hours.from_now if duration.to_i > 0 + end + + sharing_config['expires_at'] = expiration_time.iso8601 if expiration_time + sharing_config['duration'] = duration + end + + current_settings['family']['location_sharing'] = sharing_config + else + current_settings['family']['location_sharing'] = { 'enabled' => false } + end + + update!(settings: current_settings) + end + + def family_sharing_expires_at + sharing_settings = settings.dig('family', 'location_sharing') + return nil unless sharing_settings.is_a?(Hash) + + expires_at = sharing_settings['expires_at'] + Time.parse(expires_at) if expires_at.present? + rescue ArgumentError + nil + end + + def family_sharing_duration + settings.dig('family', 'location_sharing', 'duration') || 'permanent' + end + + def latest_location_for_family + return nil unless family_sharing_enabled? + + latest_point = + points.select(:lonlat, :timestamp) + .order(timestamp: :desc) + .limit(1) + .first + + return nil unless latest_point + + { + user_id: id, + email: email, + latitude: latest_point.lat, + longitude: latest_point.lon, + timestamp: latest_point.timestamp, + updated_at: Time.zone.at(latest_point.timestamp) + } + end + + private + + def check_family_ownership + return if can_delete_account? + + errors.add(:base, 'Cannot delete account while being a family owner with other members') + raise ActiveRecord::DeleteRestrictionError, 'Cannot delete user with family members' + end +end diff --git a/app/models/family.rb b/app/models/family.rb new file mode 100644 index 00000000..51123293 --- /dev/null +++ b/app/models/family.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Family < ApplicationRecord + has_many :family_memberships, dependent: :destroy, class_name: 'Family::Membership' + has_many :members, through: :family_memberships, source: :user + has_many :family_invitations, dependent: :destroy, class_name: 'Family::Invitation' + belongs_to :creator, class_name: 'User' + + validates :name, presence: true, length: { maximum: 50 } + + MAX_MEMBERS = 5 + + def can_add_members? + (member_count + pending_invitations_count) < MAX_MEMBERS + end + + def member_count + @member_count ||= members.count + end + + def pending_invitations_count + @pending_invitations_count ||= family_invitations.active.count + end + + def owners + members.joins(:family_membership) + .where(family_memberships: { role: :owner }) + end + + def owner + @owner ||= creator + end + + def full? + (member_count + pending_invitations_count) >= MAX_MEMBERS + end + + def active_invitations + family_invitations.active.includes(:invited_by) + end + + def clear_member_cache! + @member_count = nil + @pending_invitations_count = nil + @owner = nil + end +end diff --git a/app/models/family/invitation.rb b/app/models/family/invitation.rb new file mode 100644 index 00000000..a2739291 --- /dev/null +++ b/app/models/family/invitation.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Family::Invitation < ApplicationRecord + self.table_name = 'family_invitations' + + EXPIRY_DAYS = 7 + + belongs_to :family + belongs_to :invited_by, class_name: 'User' + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :token, presence: true, uniqueness: true + validates :expires_at, :status, presence: true + + enum :status, { pending: 0, accepted: 1, expired: 2, cancelled: 3 } + + scope :active, -> { where(status: :pending).where('expires_at > ?', Time.current) } + + before_validation :generate_token, :set_expiry, on: :create + + after_create :clear_family_cache + after_update :clear_family_cache, if: :saved_change_to_status? + after_destroy :clear_family_cache + + def expired? + expires_at.past? + end + + def can_be_accepted? + pending? && !expired? + end + + private + + def generate_token + self.token = SecureRandom.urlsafe_base64(32) if token.blank? + end + + def set_expiry + self.expires_at = EXPIRY_DAYS.days.from_now if expires_at.blank? + end + + def clear_family_cache + family.clear_member_cache! + end +end diff --git a/app/models/family/membership.rb b/app/models/family/membership.rb new file mode 100644 index 00000000..da982204 --- /dev/null +++ b/app/models/family/membership.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Family::Membership < ApplicationRecord + self.table_name = 'family_memberships' + + belongs_to :family + belongs_to :user + + validates :user_id, presence: true, uniqueness: true + validates :role, presence: true + + enum :role, { owner: 0, member: 1 } + + after_create :clear_family_cache + after_update :clear_family_cache + after_destroy :clear_family_cache + + private + + def clear_family_cache + family.clear_member_cache! + end +end diff --git a/app/models/import.rb b/app/models/import.rb index 74024798..4544819e 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -11,9 +11,11 @@ class Import < ApplicationRecord after_commit -> { Import::ProcessJob.perform_later(id) unless skip_background_processing }, on: :create after_commit :remove_attached_file, on: :destroy + before_commit :recalculate_stats, on: :destroy, if: -> { points.exists? } validates :name, presence: true, uniqueness: { scope: :user_id } validate :file_size_within_limit, if: -> { user.trial? } + validate :import_count_within_limit, if: -> { user.trial? } enum :status, { created: 0, processing: 1, completed: 2, failed: 3 } @@ -21,7 +23,7 @@ class Import < ApplicationRecord google_semantic_history: 0, owntracks: 1, google_records: 2, google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7, user_data_archive: 8 - } + }, allow_nil: true def process! if user_data_archive? @@ -63,8 +65,23 @@ class Import < ApplicationRecord def file_size_within_limit return unless file.attached? - if file.blob.byte_size > 11.megabytes - errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.') + return unless file.blob.byte_size > 11.megabytes + + errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.') + end + + def import_count_within_limit + return unless new_record? + + existing_imports_count = user.imports.count + return unless existing_imports_count >= 5 + + errors.add(:base, 'Trial users can only create up to 5 imports. Please subscribe to import more files.') + end + + def recalculate_stats + years_and_months_tracked.each do |year, month| + Stats::CalculatingJob.perform_later(user.id, year, month) end end end diff --git a/app/models/point.rb b/app/models/point.rb index ef00e99b..b19e828d 100644 --- a/app/models/point.rb +++ b/app/models/point.rb @@ -6,7 +6,7 @@ class Point < ApplicationRecord belongs_to :import, optional: true, counter_cache: true belongs_to :visit, optional: true - belongs_to :user + belongs_to :user, counter_cache: true belongs_to :country, optional: true belongs_to :track, optional: true @@ -17,7 +17,8 @@ class Point < ApplicationRecord index: true } - enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 }, suffix: true + enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3, connected_not_charging: 4, discharging: 5 }, + suffix: true enum :trigger, { unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3, report_location_message_event: 4, manual_event: 5, timer_based_event: 6, @@ -33,7 +34,6 @@ class Point < ApplicationRecord after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? && !reverse_geocoded? } after_create :set_country after_create_commit :broadcast_coordinates - # after_create_commit :trigger_incremental_track_generation, if: -> { import_id.nil? } # after_commit :recalculate_track, on: :update, if: -> { track.present? } def self.without_raw_data @@ -68,31 +68,56 @@ class Point < ApplicationRecord def country_name # TODO: Remove the country column in the future. - read_attribute(:country_name) || self.country&.name || read_attribute(:country) || '' + read_attribute(:country_name) || country&.name || self[:country] || '' end private # rubocop:disable Metrics/MethodLength Metrics/AbcSize def broadcast_coordinates - return unless user.safe_settings.live_map_enabled + if user.safe_settings.live_map_enabled + PointsChannel.broadcast_to( + user, + [ + lat, + lon, + battery.to_s, + altitude.to_s, + timestamp.to_s, + velocity.to_s, + id.to_s, + country_name.to_s + ] + ) + end - PointsChannel.broadcast_to( - user, - [ - lat, - lon, - battery.to_s, - altitude.to_s, - timestamp.to_s, - velocity.to_s, - id.to_s, - country_name.to_s - ] - ) + broadcast_to_family if should_broadcast_to_family? end # rubocop:enable Metrics/MethodLength + def should_broadcast_to_family? + return false unless DawarichSettings.family_feature_enabled? + return false unless user.in_family? + return false unless user.family_sharing_enabled? + + true + end + + def broadcast_to_family + FamilyLocationsChannel.broadcast_to( + user.family, + { + user_id: user.id, + email: user.email, + email_initial: user.email.first.upcase, + latitude: lat, + longitude: lon, + timestamp: timestamp.to_i, + updated_at: Time.zone.at(timestamp.to_i).iso8601 + } + ) + end + def set_country self.country_id = found_in_country&.id save! if changed? @@ -101,8 +126,4 @@ class Point < ApplicationRecord def recalculate_track track.recalculate_path_and_distance! end - - def trigger_incremental_track_generation - Tracks::IncrementalCheckJob.perform_later(user.id, id) - end end diff --git a/app/models/stat.rb b/app/models/stat.rb index 2cf26d04..1bcb2cbf 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -7,6 +7,8 @@ class Stat < ApplicationRecord belongs_to :user + before_create :generate_sharing_uuid + def distance_by_day monthly_points = points calculate_daily_distances(monthly_points) @@ -24,14 +26,115 @@ class Stat < ApplicationRecord end def points - user.tracked_points + user.points .without_raw_data .where(timestamp: timespan) .order(timestamp: :asc) end + def sharing_enabled? + sharing_settings.try(:[], 'enabled') == true + end + + def sharing_expired? + expiration = sharing_settings.try(:[], 'expiration') + return false if expiration.blank? + + expires_at_value = sharing_settings.try(:[], 'expires_at') + return true if expires_at_value.blank? + + expires_at = begin + Time.zone.parse(expires_at_value) + rescue StandardError + nil + end + + expires_at.present? ? Time.current > expires_at : true + end + + def public_accessible? + sharing_enabled? && !sharing_expired? + end + + def hexagons_available? + h3_hex_ids.present? && + (h3_hex_ids.is_a?(Hash) || h3_hex_ids.is_a?(Array)) && + h3_hex_ids.any? + end + + def generate_new_sharing_uuid! + update!(sharing_uuid: SecureRandom.uuid) + end + + def enable_sharing!(expiration: '1h') + # Default to 24h if an invalid expiration is provided + expiration = '24h' unless %w[1h 12h 24h].include?(expiration) + + expires_at = case expiration + when '1h' then 1.hour.from_now + when '12h' then 12.hours.from_now + when '24h' then 24.hours.from_now + end + + update!( + sharing_settings: { + 'enabled' => true, + 'expiration' => expiration, + 'expires_at' => expires_at.iso8601 + }, + sharing_uuid: sharing_uuid || SecureRandom.uuid + ) + end + + def disable_sharing! + update!( + sharing_settings: { + 'enabled' => false, + 'expiration' => nil, + 'expires_at' => nil + } + ) + end + + def calculate_data_bounds + start_date = Date.new(year, month, 1).beginning_of_day + end_date = start_date.end_of_month.end_of_day + + points_relation = user.points.where(timestamp: start_date.to_i..end_date.to_i) + point_count = points_relation.count + + return nil if point_count.zero? + + bounds_result = ActiveRecord::Base.connection.exec_query( + "SELECT MIN(ST_Y(lonlat::geometry)) as min_lat, MAX(ST_Y(lonlat::geometry)) as max_lat, + MIN(ST_X(lonlat::geometry)) as min_lng, MAX(ST_X(lonlat::geometry)) as max_lng + FROM points + WHERE user_id = $1 + AND timestamp BETWEEN $2 AND $3 + AND lonlat IS NOT NULL", + 'data_bounds_query', + [user.id, start_date.to_i, end_date.to_i] + ).first + + { + min_lat: bounds_result['min_lat'].to_f, + max_lat: bounds_result['max_lat'].to_f, + min_lng: bounds_result['min_lng'].to_f, + max_lng: bounds_result['max_lng'].to_f, + point_count: point_count + } + end + + def process! + Stats::CalculatingJob.perform_later(user.id, year, month) + end + private + def generate_sharing_uuid + self.sharing_uuid ||= SecureRandom.uuid + end + def timespan DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month end @@ -40,8 +143,6 @@ class Stat < ApplicationRecord Stats::DailyDistanceQuery.new(monthly_points, timespan, user_timezone).call end - private - def user_timezone # Future: Once user.timezone column exists, uncomment the line below # user.timezone.presence || Time.zone.name diff --git a/app/models/trip.rb b/app/models/trip.rb index e409a47b..fca5e1e2 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -18,7 +18,7 @@ class Trip < ApplicationRecord end def points - user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) + user.points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp) end def photo_previews diff --git a/app/models/user.rb b/app/models/user.rb index 3f4046a0..c5d5d337 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true class User < ApplicationRecord # rubocop:disable Metrics/ClassLength + include UserFamily devise :database_authenticatable, :registerable, :recoverable, :rememberable, :validatable, :trackable - has_many :tracked_points, class_name: 'Point', dependent: :destroy + has_many :points, dependent: :destroy, counter_cache: true has_many :imports, dependent: :destroy has_many :stats, dependent: :destroy has_many :exports, dependent: :destroy has_many :notifications, dependent: :destroy has_many :areas, dependent: :destroy has_many :visits, dependent: :destroy - has_many :points, through: :imports has_many :places, through: :visits has_many :trips, dependent: :destroy has_many :tracks, dependent: :destroy @@ -19,14 +19,16 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength after_create :create_api_key after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? } - after_commit :schedule_welcome_emails, on: :create, if: -> { !DawarichSettings.self_hosted? } + before_save :sanitize_input validates :email, presence: true - validates :reset_password_token, uniqueness: true, allow_nil: true attribute :admin, :boolean, default: false + attribute :points_count, :integer, default: 0 + + scope :active_or_trial, -> { where(status: %i[active trial]) } enum :status, { inactive: 0, active: 1, trial: 2 } @@ -35,15 +37,20 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength end def countries_visited - tracked_points - .where.not(country_name: [nil, '']) - .distinct - .pluck(:country_name) - .compact + Rails.cache.fetch("dawarich/user_#{id}_countries_visited", expires_in: 1.day) do + points + .without_raw_data + .where.not(country_name: [nil, '']) + .distinct + .pluck(:country_name) + .compact + end end def cities_visited - tracked_points.where.not(city: [nil, '']).distinct.pluck(:city).compact + Rails.cache.fetch("dawarich/user_#{id}_cities_visited", expires_in: 1.day) do + points.where.not(city: [nil, '']).distinct.pluck(:city).compact + end end def total_distance @@ -60,11 +67,11 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength end def total_reverse_geocoded_points - tracked_points.where.not(reverse_geocoded_at: nil).count + points.where.not(reverse_geocoded_at: nil).count end def total_reverse_geocoded_points_without_data - tracked_points.where(geodata: {}).count + points.where(geodata: {}).count end def immich_integration_configured? @@ -118,7 +125,24 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength end def trial_state? - tracked_points.none? && trial? + (points_count || 0).zero? && trial? + end + + def timezone + Time.zone.name + end + + def countries_visited_uncached + points + .without_raw_data + .where.not(country_name: [nil, '']) + .distinct + .pluck(:country_name) + .compact + end + + def cities_visited_uncached + points.where.not(city: [nil, '']).distinct.pluck(:city).compact end private @@ -141,6 +165,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength def start_trial update(status: :trial, active_until: 7.days.from_now) + schedule_welcome_emails Users::TrialWebhookJob.perform_later(id) end @@ -150,5 +175,11 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features') Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon') Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired') + schedule_post_trial_emails + end + + def schedule_post_trial_emails + Users::MailerSendingJob.set(wait: 9.days).perform_later(id, 'post_trial_reminder_early') + Users::MailerSendingJob.set(wait: 14.days).perform_later(id, 'post_trial_reminder_late') end end diff --git a/app/models/visit.rb b/app/models/visit.rb index 794cfe06..936fdc7d 100644 --- a/app/models/visit.rb +++ b/app/models/visit.rb @@ -10,6 +10,8 @@ class Visit < ApplicationRecord validates :started_at, :ended_at, :duration, :name, :status, presence: true + validates :ended_at, comparison: { greater_than: :started_at } + enum :status, { suggested: 0, confirmed: 1, declined: 2 } def coordinates diff --git a/app/policies/family/invitation_policy.rb b/app/policies/family/invitation_policy.rb new file mode 100644 index 00000000..2f0b59f5 --- /dev/null +++ b/app/policies/family/invitation_policy.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Family::InvitationPolicy < ApplicationPolicy + def create? + return false unless user + + user.family == record.family && user.family_owner? + end + + def accept? + return false unless user + + user.email == record.email + end + + def destroy? + create? + end +end diff --git a/app/policies/family/membership_policy.rb b/app/policies/family/membership_policy.rb new file mode 100644 index 00000000..d77c7b14 --- /dev/null +++ b/app/policies/family/membership_policy.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Family::MembershipPolicy < ApplicationPolicy + def create? + return false unless user + return false unless record.is_a?(Family::Invitation) + + record.email == user.email && record.pending? && !record.expired? + end + + def destroy? + return false unless user + return true if user == record.user + + user.family == record.family && user.family_owner? + end +end diff --git a/app/policies/family_invitation_policy.rb b/app/policies/family_invitation_policy.rb new file mode 100644 index 00000000..2369458b --- /dev/null +++ b/app/policies/family_invitation_policy.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class FamilyInvitationPolicy < ApplicationPolicy + def show? + # Public endpoint for invitation acceptance - no authentication required + true + end + + def create? + user.family == record.family && user.family_owner? + end + + def accept? + # Users can accept invitations sent to their email + user.email == record.email + end + + def destroy? + # Only family owners can cancel invitations + user.family == record.family && user.family_owner? + end +end diff --git a/app/policies/family_membership_policy.rb b/app/policies/family_membership_policy.rb new file mode 100644 index 00000000..1b50c18e --- /dev/null +++ b/app/policies/family_membership_policy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class FamilyMembershipPolicy < ApplicationPolicy + def show? + user.family == record.family + end + + def update? + # Users can update their own settings + return true if user == record.user + + # Family owners can update any member's settings + user.family == record.family && user.family_owner? + end + + def destroy? + # Users can remove themselves (handled by family leave logic) + return true if user == record.user + + # Family owners can remove other members + user.family == record.family && user.family_owner? + end +end diff --git a/app/policies/family_policy.rb b/app/policies/family_policy.rb new file mode 100644 index 00000000..b644de53 --- /dev/null +++ b/app/policies/family_policy.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class FamilyPolicy < ApplicationPolicy + def show? + user.family == record + end + + def create? + return false if user.in_family? + return true if DawarichSettings.self_hosted? + + # Add cloud subscription checks here when implemented + # For now, allow all users to create families + true + end + + def update? + user.family == record && user.family_owner? + end + + def destroy? + user.family == record && user.family_owner? + end + + def leave? + user.family == record && !family_owner_with_members? + end + + def invite? + user.family == record && user.family_owner? + end + + def manage_invitations? + user.family == record && user.family_owner? + end + + private + + def family_owner_with_members? + user.family_owner? && record.members.count > 1 + end +end diff --git a/app/queries/stats_query.rb b/app/queries/stats_query.rb index 0192a8c8..a2fe5c10 100644 --- a/app/queries/stats_query.rb +++ b/app/queries/stats_query.rb @@ -6,22 +6,34 @@ class StatsQuery end def points_stats - sql = ActiveRecord::Base.sanitize_sql_array([ - <<~SQL.squish, - SELECT - COUNT(id) as total, - COUNT(reverse_geocoded_at) as geocoded, - COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data - FROM points - WHERE user_id = ? - SQL - user.id - ]) + cached_stats = Rails.cache.fetch("dawarich/user_#{user.id}_points_geocoded_stats", expires_in: 1.day) do + cached_points_geocoded_stats + end + + { + total: user.points_count, + geocoded: cached_stats[:geocoded], + without_data: cached_stats[:without_data] + } + end + + def cached_points_geocoded_stats + sql = ActiveRecord::Base.sanitize_sql_array( + [ + <<~SQL.squish, + SELECT + COUNT(reverse_geocoded_at) as geocoded, + COUNT(CASE WHEN geodata = '{}'::jsonb THEN 1 END) as without_data + FROM points + WHERE user_id = ? + SQL + user.id + ] + ) result = Point.connection.select_one(sql) { - total: result['total'].to_i, geocoded: result['geocoded'].to_i, without_data: result['without_data'].to_i } diff --git a/app/serializers/api/location_search_result_serializer.rb b/app/serializers/api/location_search_result_serializer.rb new file mode 100644 index 00000000..6cbc967e --- /dev/null +++ b/app/serializers/api/location_search_result_serializer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Api::LocationSearchResultSerializer + def initialize(search_result) + @search_result = search_result + end + + def call + { + query: @search_result[:query], + locations: serialize_locations(@search_result[:locations]), + total_locations: @search_result[:total_locations], + search_metadata: @search_result[:search_metadata] + } + end + + private + + def serialize_locations(locations) + locations.map do |location| + { + place_name: location[:place_name], + coordinates: location[:coordinates], + address: location[:address], + total_visits: location[:total_visits], + first_visit: location[:first_visit], + last_visit: location[:last_visit], + visits: serialize_visits(location[:visits]) + } + end + end + + def serialize_visits(visits) + visits.map do |visit| + { + timestamp: visit[:timestamp], + date: visit[:date], + coordinates: visit[:coordinates], + distance_meters: visit[:distance_meters], + duration_estimate: visit[:duration_estimate], + points_count: visit[:points_count], + accuracy_meters: visit[:accuracy_meters], + visit_details: visit[:visit_details] + } + end + end +end diff --git a/app/serializers/api/photo_serializer.rb b/app/serializers/api/photo_serializer.rb index c0a1119a..c21e9c6f 100644 --- a/app/serializers/api/photo_serializer.rb +++ b/app/serializers/api/photo_serializer.rb @@ -68,6 +68,8 @@ class Api::PhotoSerializer photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape' when 'photoprism' photo['Portrait'] ? 'portrait' : 'landscape' + else + 'landscape' # default orientation for nil or unknown source end end end diff --git a/app/serializers/api/point_serializer.rb b/app/serializers/api/point_serializer.rb index e8484d38..fd8dec19 100644 --- a/app/serializers/api/point_serializer.rb +++ b/app/serializers/api/point_serializer.rb @@ -1,9 +1,26 @@ # frozen_string_literal: true -class Api::PointSerializer < PointSerializer - EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data country_id].freeze +class Api::PointSerializer + EXCLUDED_ATTRIBUTES = %w[ + created_at updated_at visit_id import_id user_id raw_data + country_id + ].freeze + + def initialize(point) + @point = point + end def call - point.attributes.except(*EXCLUDED_ATTRIBUTES) + point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes| + lat = point.lat + lon = point.lon + + attributes['latitude'] = lat&.to_s + attributes['longitude'] = lon&.to_s + end end + + private + + attr_reader :point end diff --git a/app/serializers/api/user_serializer.rb b/app/serializers/api/user_serializer.rb index d3e89dfe..9d54ec32 100644 --- a/app/serializers/api/user_serializer.rb +++ b/app/serializers/api/user_serializer.rb @@ -6,15 +6,19 @@ class Api::UserSerializer end def call - { + data = { user: { email: user.email, theme: user.theme, created_at: user.created_at, updated_at: user.updated_at, - settings: settings, + settings: settings } } + + data.merge!(subscription: subscription) unless DawarichSettings.self_hosted? + + data end private @@ -41,4 +45,11 @@ class Api::UserSerializer fog_of_war_threshold: user.safe_settings.fog_of_war_threshold } end + + def subscription + { + status: user.status, + active_until: user.active_until + } + end end diff --git a/app/serializers/stats_serializer.rb b/app/serializers/stats_serializer.rb index 3a35f157..bd3939fb 100644 --- a/app/serializers/stats_serializer.rb +++ b/app/serializers/stats_serializer.rb @@ -10,7 +10,7 @@ class StatsSerializer def call { totalDistanceKm: total_distance_km, - totalPointsTracked: user.tracked_points.count, + totalPointsTracked: user.points_count.to_i, totalReverseGeocodedPoints: reverse_geocoded_points, totalCountriesVisited: user.countries_visited.count, totalCitiesVisited: user.cities_visited.count, @@ -27,7 +27,7 @@ class StatsSerializer end def reverse_geocoded_points - user.tracked_points.reverse_geocoded.count + user.points.reverse_geocoded.count end def yearly_stats diff --git a/app/services/cache/clean.rb b/app/services/cache/clean.rb index 15647b99..ecbfafed 100644 --- a/app/services/cache/clean.rb +++ b/app/services/cache/clean.rb @@ -7,6 +7,8 @@ class Cache::Clean delete_control_flag delete_version_cache delete_years_tracked_cache + delete_points_geocoded_stats_cache + delete_countries_cities_cache Rails.logger.info('Cache cleaned') end @@ -25,5 +27,18 @@ class Cache::Clean Rails.cache.delete("dawarich/user_#{user.id}_years_tracked") end end + + def delete_points_geocoded_stats_cache + User.find_each do |user| + Rails.cache.delete("dawarich/user_#{user.id}_points_geocoded_stats") + end + end + + def delete_countries_cities_cache + User.find_each do |user| + Rails.cache.delete("dawarich/user_#{user.id}_countries") + Rails.cache.delete("dawarich/user_#{user.id}_cities") + end + end end end diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index ac4b2451..333cb7ac 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -11,7 +11,7 @@ class CountriesAndCities def call points .reject { |point| point.country_name.nil? || point.city.nil? } - .group_by { |point| point.country_name } + .group_by(&:country_name) .transform_values { |country_points| process_country_points(country_points) } .map { |country, cities| CountryData.new(country: country, cities: cities) } end diff --git a/app/services/exports/create.rb b/app/services/exports/create.rb index d885afb8..0c2feb76 100644 --- a/app/services/exports/create.rb +++ b/app/services/exports/create.rb @@ -35,7 +35,7 @@ class Exports::Create def time_framed_points user - .tracked_points + .points .where(timestamp: start_at.to_i..end_at.to_i) .order(timestamp: :asc) end diff --git a/app/services/families/accept_invitation.rb b/app/services/families/accept_invitation.rb new file mode 100644 index 00000000..3e327a43 --- /dev/null +++ b/app/services/families/accept_invitation.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Families + class AcceptInvitation + attr_reader :invitation, :user, :error_message + + def initialize(invitation:, user:) + @invitation = invitation + @user = user + @error_message = nil + end + + def call + return false unless can_accept? + + if user.in_family? + @error_message = 'You must leave your current family before joining a new one.' + + return false + end + + ActiveRecord::Base.transaction do + create_membership + update_invitation + send_notifications + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + false + rescue StandardError => e + handle_generic_error(e) + false + end + + private + + def can_accept? + return false unless validate_invitation + return false unless validate_email_match + return false unless validate_family_capacity + + true + end + + def validate_invitation + return true if invitation.can_be_accepted? + + @error_message = 'This invitation is no longer valid or has expired.' + + false + end + + def validate_email_match + return true if invitation.email == user.email + + @error_message = 'This invitation is not for your email address.' + + false + end + + def validate_family_capacity + return true unless invitation.family.full? + + @error_message = 'This family has reached the maximum number of members.' + + false + end + + def create_membership + Family::Membership.create!( + family: invitation.family, + user: user, + role: :member + ) + end + + def update_invitation + invitation.update!(status: :accepted) + end + + def send_notifications + send_user_notification + send_owner_notification + end + + def send_user_notification + Notification.create!( + user: user, + kind: :info, + title: 'Welcome to Family!', + content: "You've joined the family '#{invitation.family.name}'" + ) + end + + def send_owner_notification + Notification.create!( + user: invitation.family.creator, + kind: :info, + title: 'New Family Member!', + content: "#{user.email} has joined your family" + ) + rescue StandardError => e + ExceptionReporter.call(e, "Unexpected error in Families::AcceptInvitation: #{e.message}") + end + + def handle_record_invalid_error(error) + @error_message = + if error.record&.errors&.any? + error.record.errors.full_messages.first + else + "Failed to join family: #{error.message}" + end + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::AcceptInvitation: #{error.message}") + + @error_message = 'An unexpected error occurred while joining the family. Please try again' + end + end +end diff --git a/app/services/families/create.rb b/app/services/families/create.rb new file mode 100644 index 00000000..08135f99 --- /dev/null +++ b/app/services/families/create.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Families + class Create + include ActiveModel::Validations + + attr_reader :user, :name, :family, :error_message + + validates :name, presence: { message: 'Family name is required' } + validates :name, length: { + maximum: 50, + message: 'Family name must be 50 characters or less' + } + + def initialize(user:, name:) + @user = user + @name = name&.strip + @error_message = nil + end + + def call + return false unless valid? + return false unless validate_user_eligibility + return false unless validate_feature_access + + ActiveRecord::Base.transaction do + create_family + create_owner_membership + send_notification + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + + false + rescue ActiveRecord::RecordNotUnique => e + handle_uniqueness_error(e) + + false + rescue StandardError => e + handle_generic_error(e) + + false + end + + private + + def validate_user_eligibility + if user.in_family? + @error_message = 'You must leave your current family before creating a new one' + return false + end + + if user.created_family.present? + @error_message = 'You have already created a family. Each user can only create one family' + return false + end + + true + end + + def validate_feature_access + return true if can_create_family? + + @error_message = + if DawarichSettings.self_hosted? + 'Family feature is not available on this instance' + else + 'Family feature requires an active subscription' + end + + false + end + + def can_create_family? + return true if DawarichSettings.self_hosted? + + # TODO: Add cloud plan validation here when needed + # For now, allow all users to create families + true + end + + def create_family + @family = Family.create!(name: name, creator: user) + end + + def create_owner_membership + Family::Membership.create!( + family: family, + user: user, + role: :owner + ) + end + + def send_notification + Notification.create!( + user: user, + kind: :info, + title: 'Family Created', + content: "You've successfully created the family '#{family.name}'" + ) + rescue StandardError => e + # Don't fail the entire operation if notification fails + ExceptionReporter.call(e, "Unexpected error in Families::Create: #{e.message}") + end + + def handle_record_invalid_error(error) + @error_message = + if family&.errors&.any? + family.errors.full_messages.first + else + "Failed to create family: #{error.message}" + end + end + + def handle_uniqueness_error(_error) + @error_message = 'A family with this name already exists for your account' + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::Create: #{error.message}") + @error_message = 'An unexpected error occurred while creating the family. Please try again' + end + end +end diff --git a/app/services/families/invite.rb b/app/services/families/invite.rb new file mode 100644 index 00000000..c1d7796b --- /dev/null +++ b/app/services/families/invite.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Families + class Invite + include ActiveModel::Validations + + attr_reader :family, :email, :invited_by, :invitation + + validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + + def initialize(family:, email:, invited_by:) + @family = family + @email = email.downcase.strip + @invited_by = invited_by + end + + def call + return false unless valid? + return false unless invite_sendable? + + ActiveRecord::Base.transaction do + create_invitation + send_invitation_email + send_notification + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + false + rescue Net::SMTPError => e + handle_email_error(e) + false + rescue StandardError => e + handle_generic_error(e) + false + end + + def error_message + return errors.full_messages.first if errors.any? + return @custom_error_message if @custom_error_message + + 'Failed to send invitation' + end + + private + + def invite_sendable? + unless invited_by.family_owner? + return add_error_and_false(:invited_by, + 'You must be a family owner to send invitations') + end + return add_error_and_false(:family, 'Family is full') if family.full? + return add_error_and_false(:email, 'User is already in a family') if user_already_in_family? + return add_error_and_false(:email, 'Invitation already sent to this email') if pending_invitation_exists? + + true + end + + def add_error_and_false(attribute, message) + errors.add(attribute, message) + false + end + + def user_already_in_family? + User.joins(:family_membership) + .where(email: email) + .exists? + end + + def pending_invitation_exists? + family.family_invitations.active.where(email: email).exists? + end + + def create_invitation + @invitation = Family::Invitation.create!( + family: family, + email: email, + invited_by: invited_by + ) + end + + def send_invitation_email + # Send email in background with retry logic + FamilyMailer.invitation(@invitation).deliver_later( + queue: :mailer, + retry: 3, + wait: 30.seconds + ) + end + + def send_notification + Notification.create!( + user: invited_by, + kind: :info, + title: 'Invitation Sent', + content: "Family invitation sent to #{email}" + ) + rescue StandardError => e + # Don't fail the entire operation if notification fails + ExceptionReporter.call(e, "Unexpected error in Families::Invite: #{e.message}") + end + + def handle_record_invalid_error(error) + @custom_error_message = if invitation&.errors&.any? + invitation.errors.full_messages.first + else + "Failed to create invitation: #{error.message}" + end + end + + def handle_email_error(error) + Rails.logger.error "Email delivery failed for family invitation: #{error.message}" + @custom_error_message = 'Failed to send invitation email. Please try again later' + + # Clean up the invitation if email fails + invitation&.destroy + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::Invite: #{error.message}") + @custom_error_message = 'An unexpected error occurred while sending the invitation. Please try again' + end + end +end diff --git a/app/services/families/locations.rb b/app/services/families/locations.rb new file mode 100644 index 00000000..2e4f2296 --- /dev/null +++ b/app/services/families/locations.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Families::Locations + attr_reader :user + + def initialize(user) + @user = user + end + + def call + return [] unless family_feature_enabled? + return [] unless user.in_family? + + sharing_members = family_members_with_sharing_enabled + return [] unless sharing_members.any? + + build_family_locations(sharing_members) + end + + private + + def family_feature_enabled? + DawarichSettings.family_feature_enabled? + end + + def family_members_with_sharing_enabled + user.family.members + .where.not(id: user.id) + .select(&:family_sharing_enabled?) + end + + def build_family_locations(sharing_members) + latest_points = + sharing_members.map { _1.points.last }.compact + + latest_points.map do |point| + { + user_id: point.user_id, + email: point.user.email, + email_initial: point.user.email.first.upcase, + latitude: point.lat, + longitude: point.lon, + timestamp: point.timestamp.to_i, + updated_at: Time.zone.at(point.timestamp.to_i) + } + end + end +end diff --git a/app/services/families/memberships/destroy.rb b/app/services/families/memberships/destroy.rb new file mode 100644 index 00000000..efdbc914 --- /dev/null +++ b/app/services/families/memberships/destroy.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +module Families + module Memberships + class Destroy + attr_reader :user, :member_to_remove, :error_message + + def initialize(user:, member_to_remove: nil) + @user = user + @member_to_remove = member_to_remove || user + @error_message = nil + end + + def call + return false unless validate_can_leave + + @family_name = member_to_remove.family.name + @family_owner = member_to_remove.family.owner + + ActiveRecord::Base.transaction do + remove_membership + send_notifications + end + + true + rescue ActiveRecord::RecordInvalid => e + handle_record_invalid_error(e) + + false + rescue StandardError => e + handle_generic_error(e) + + false + end + + private + + def validate_can_leave + return false unless validate_in_family + return false unless validate_removal_allowed + + true + end + + def validate_in_family + return true if member_to_remove.in_family? + + @error_message = 'User is not currently in a family.' + false + end + + def validate_removal_allowed + return validate_owner_can_leave if removing_self? + + return false unless validate_remover_is_owner + return false unless validate_same_family + return false unless validate_not_removing_owner + + true + end + + def removing_self? + user == member_to_remove + end + + def validate_owner_can_leave + return true unless member_to_remove.family_owner? + + @error_message = 'Family owners cannot remove their own membership. To leave the family, delete it instead.' + false + end + + def validate_remover_is_owner + return true if user.family_owner? + + @error_message = 'Only family owners can remove other members.' + false + end + + def validate_same_family + return true if user.family == member_to_remove.family + + @error_message = 'Cannot remove members from a different family.' + false + end + + def validate_not_removing_owner + return true unless member_to_remove.family_owner? + + @error_message = 'Cannot remove the family owner. The owner must delete the family or leave on their own.' + false + end + + def remove_membership + member_to_remove.family_membership.destroy! + end + + def send_notifications + if removing_self? + send_self_removal_notifications + else + send_member_removed_notifications + end + end + + def send_self_removal_notifications + Notification.create!( + user: member_to_remove, + kind: :info, + title: 'Left Family', + content: "You've left the family \"#{@family_name}\"" + ) + + return unless @family_owner&.persisted? + + Notification.create!( + user: @family_owner, + kind: :info, + title: 'Family Member Left', + content: "#{member_to_remove.email} has left the family \"#{@family_name}\"" + ) + end + + def send_member_removed_notifications + Notification.create!( + user: member_to_remove, + kind: :info, + title: 'Removed from Family', + content: "You have been removed from the family \"#{@family_name}\" by #{user.email}" + ) + + return unless user != member_to_remove + + Notification.create!( + user: user, + kind: :info, + title: 'Member Removed', + content: "#{member_to_remove.email} has been removed from the family \"#{@family_name}\"" + ) + end + + def handle_record_invalid_error(error) + @error_message = + if error.record&.errors&.any? + error.record.errors.full_messages.first + else + "Failed to leave family: #{error.message}" + end + end + + def handle_generic_error(error) + ExceptionReporter.call(error, "Unexpected error in Families::Memberships::Destroy: #{error.message}") + @error_message = 'An unexpected error occurred while removing the membership. Please try again' + end + end + end +end diff --git a/app/services/families/update_location_sharing.rb b/app/services/families/update_location_sharing.rb new file mode 100644 index 00000000..a0cc9f12 --- /dev/null +++ b/app/services/families/update_location_sharing.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Families::UpdateLocationSharing + Result = Struct.new(:success?, :payload, :status, keyword_init: true) + + def initialize(user:, enabled:, duration:) + @user = user + @enabled_param = enabled + @duration_param = duration + @boolean_caster = ActiveModel::Type::Boolean.new + end + + def call + return success_result if update_location_sharing + + failure_result('Failed to update location sharing setting', :unprocessable_content) + rescue => error + ExceptionReporter.call(error, "Error in Families::UpdateLocationSharing: #{error.message}") + + failure_result('An error occurred while updating location sharing', :internal_server_error) + end + + private + + attr_reader :user, :enabled_param, :duration_param, :boolean_caster + + def update_location_sharing + user.update_family_location_sharing!(enabled?, duration: duration_param) + end + + def enabled? + @enabled ||= boolean_caster.cast(enabled_param) + end + + def success_result + payload = { + success: true, + enabled: enabled?, + duration: user.family_sharing_duration, + message: build_sharing_message + } + + if enabled? && user.family_sharing_expires_at.present? + payload[:expires_at] = user.family_sharing_expires_at.iso8601 + payload[:expires_at_formatted] = user.family_sharing_expires_at.strftime('%b %d at %I:%M %p') + end + + Result.new(success?: true, payload: payload, status: :ok) + end + + def failure_result(message, status) + Result.new(success?: false, payload: { success: false, message: message }, status: status) + end + + def build_sharing_message + return 'Location sharing disabled' unless enabled? + + case duration_param + when '1h' then 'Location sharing enabled for 1 hour' + when '6h' then 'Location sharing enabled for 6 hours' + when '12h' then 'Location sharing enabled for 12 hours' + when '24h' then 'Location sharing enabled for 24 hours' + when 'permanent', nil then 'Location sharing enabled' + else + duration_param.to_i.positive? ? "Location sharing enabled for #{duration_param.to_i} hours" : 'Location sharing enabled' + end + end +end diff --git a/app/services/geojson/importer.rb b/app/services/geojson/importer.rb index 9967cd49..94230047 100644 --- a/app/services/geojson/importer.rb +++ b/app/services/geojson/importer.rb @@ -2,19 +2,19 @@ class Geojson::Importer include Imports::Broadcaster + include Imports::FileLoader include PointValidation - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - json = Oj.load(file_content) - + json = load_json_data data = Geojson::Params.new(json).call data.each.with_index(1) do |point, index| diff --git a/app/services/google_maps/phone_takeout_importer.rb b/app/services/google_maps/phone_takeout_importer.rb index 90f75f72..51cfda5c 100644 --- a/app/services/google_maps/phone_takeout_importer.rb +++ b/app/services/google_maps/phone_takeout_importer.rb @@ -2,12 +2,14 @@ class GoogleMaps::PhoneTakeoutImporter include Imports::Broadcaster + include Imports::FileLoader - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call @@ -46,9 +48,7 @@ class GoogleMaps::PhoneTakeoutImporter raw_signals = [] raw_array = [] - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - - json = Oj.load(file_content) + json = load_json_data if json.is_a?(Array) raw_array = parse_raw_array(json) diff --git a/app/services/google_maps/records_storage_importer.rb b/app/services/google_maps/records_storage_importer.rb index 28c80bc8..f47c15fd 100644 --- a/app/services/google_maps/records_storage_importer.rb +++ b/app/services/google_maps/records_storage_importer.rb @@ -4,11 +4,14 @@ # via the UI, vs the CLI, which uses the `GoogleMaps::RecordsImporter` class. class GoogleMaps::RecordsStorageImporter + include Imports::FileLoader + BATCH_SIZE = 1000 - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user = User.find_by(id: user_id) + @file_path = file_path end def call @@ -20,21 +23,16 @@ class GoogleMaps::RecordsStorageImporter private - attr_reader :import, :user + attr_reader :import, :user, :file_path def process_file_in_batches - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - locations = parse_file(file_content) + parsed_file = load_json_data + return unless parsed_file.is_a?(Hash) && parsed_file['locations'] + + locations = parsed_file['locations'] process_locations_in_batches(locations) if locations.present? end - def parse_file(file_content) - parsed_file = Oj.load(file_content, mode: :compat) - return nil unless parsed_file.is_a?(Hash) && parsed_file['locations'] - - parsed_file['locations'] - end - def process_locations_in_batches(locations) batch = [] index = 0 diff --git a/app/services/google_maps/semantic_history_importer.rb b/app/services/google_maps/semantic_history_importer.rb index ae6209b4..e5eeb0b9 100644 --- a/app/services/google_maps/semantic_history_importer.rb +++ b/app/services/google_maps/semantic_history_importer.rb @@ -2,13 +2,15 @@ class GoogleMaps::SemanticHistoryImporter include Imports::Broadcaster + include Imports::FileLoader BATCH_SIZE = 1000 - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path @current_index = 0 end @@ -61,8 +63,7 @@ class GoogleMaps::SemanticHistoryImporter end def points_data - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - json = Oj.load(file_content) + json = load_json_data json['timelineObjects'].flat_map do |timeline_object| parse_timeline_object(timeline_object) diff --git a/app/services/gpx/track_importer.rb b/app/services/gpx/track_importer.rb index e0207292..2a25cc99 100644 --- a/app/services/gpx/track_importer.rb +++ b/app/services/gpx/track_importer.rb @@ -4,16 +4,18 @@ require 'rexml/document' class Gpx::TrackImporter include Imports::Broadcaster + include Imports::FileLoader - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification + file_content = load_file_content json = Hash.from_xml(file_content) tracks = json['gpx']['trk'] diff --git a/app/services/imports/create.rb b/app/services/imports/create.rb index d7ad2323..d920f374 100644 --- a/app/services/imports/create.rb +++ b/app/services/imports/create.rb @@ -14,17 +14,33 @@ class Imports::Create import.update!(status: :processing) broadcast_status_update - importer(import.source).new(import, user.id).call + temp_file_path = Imports::SecureFileDownloader.new(import.file).download_to_temp_file + + source = if import.source.nil? || should_detect_source? + detect_source_from_file(temp_file_path) + else + import.source + end + + import.update!(source: source) + importer(source).new(import, user.id, temp_file_path).call schedule_stats_creating(user.id) schedule_visit_suggesting(user.id, import) update_import_points_count(import) + User.reset_counters(user.id, :points) rescue StandardError => e import.update!(status: :failed) broadcast_status_update + ExceptionReporter.call(e, 'Import failed') + create_import_failed_notification(import, user, e) ensure + if temp_file_path && File.exist?(temp_file_path) + File.unlink(temp_file_path) + end + if import.processing? import.update!(status: :completed) broadcast_status_update @@ -34,7 +50,9 @@ class Imports::Create private def importer(source) - case source + raise ArgumentError, 'Import source cannot be nil' if source.nil? + + case source.to_s when 'google_semantic_history' then GoogleMaps::SemanticHistoryImporter when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutImporter when 'google_records' then GoogleMaps::RecordsStorageImporter @@ -42,6 +60,8 @@ class Imports::Create when 'gpx' then Gpx::TrackImporter when 'geojson' then Geojson::Importer when 'immich_api', 'photoprism_api' then Photos::Importer + else + raise ArgumentError, "Unsupported source: #{source}" end end @@ -58,12 +78,11 @@ class Imports::Create def schedule_visit_suggesting(user_id, import) return unless user.safe_settings.visits_suggestions_enabled? - points = import.points.order(:timestamp) + min_max = import.points.pluck('MIN(timestamp), MAX(timestamp)').first + return if min_max.compact.empty? - return if points.none? - - start_at = Time.zone.at(points.first.timestamp) - end_at = Time.zone.at(points.last.timestamp) + start_at = Time.zone.at(min_max[0]) + end_at = Time.zone.at(min_max[1]) VisitSuggestingJob.perform_later(user_id:, start_at:, end_at:) end @@ -79,6 +98,17 @@ class Imports::Create ).call end + def should_detect_source? + # Don't override API-based sources that can't be reliably detected + !%w[immich_api photoprism_api].include?(import.source) + end + + def detect_source_from_file(temp_file_path) + detector = Imports::SourceDetector.new_from_file_header(temp_file_path) + + detector.detect_source! + end + def import_failed_message(import, error) if DawarichSettings.self_hosted? "Import \"#{import.name}\" failed: #{error.message}, stacktrace: #{error.backtrace.join("\n")}" diff --git a/app/services/imports/file_loader.rb b/app/services/imports/file_loader.rb new file mode 100644 index 00000000..b26d4188 --- /dev/null +++ b/app/services/imports/file_loader.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Imports + module FileLoader + extend ActiveSupport::Concern + + private + + def load_json_data + if file_path && File.exist?(file_path) + Oj.load_file(file_path, mode: :compat) + else + file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification + Oj.load(file_content, mode: :compat) + end + end + + def load_file_content + if file_path && File.exist?(file_path) + File.read(file_path) + else + Imports::SecureFileDownloader.new(import.file).download_with_verification + end + end + end +end diff --git a/app/services/imports/secure_file_downloader.rb b/app/services/imports/secure_file_downloader.rb index f4bd2091..d92e64be 100644 --- a/app/services/imports/secure_file_downloader.rb +++ b/app/services/imports/secure_file_downloader.rb @@ -9,6 +9,63 @@ class Imports::SecureFileDownloader end def download_with_verification + file_content = download_to_string + verify_file_integrity(file_content) + file_content + end + + def download_to_temp_file + retries = 0 + temp_file = nil + + begin + Timeout.timeout(DOWNLOAD_TIMEOUT) do + temp_file = create_temp_file + + # Download directly to temp file + storage_attachment.download do |chunk| + temp_file.write(chunk) + end + temp_file.rewind + + # If file is empty, try alternative download method + if temp_file.size == 0 + Rails.logger.warn('No content received from block download, trying alternative method') + temp_file.write(storage_attachment.blob.download) + temp_file.rewind + end + end + rescue Timeout::Error => e + retries += 1 + if retries <= MAX_RETRIES + Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}") + cleanup_temp_file(temp_file) + retry + else + Rails.logger.error("Download failed after #{MAX_RETRIES} attempts") + cleanup_temp_file(temp_file) + raise + end + rescue StandardError => e + Rails.logger.error("Download error: #{e.message}") + cleanup_temp_file(temp_file) + raise + end + + raise 'Download completed but no content was received' if temp_file.size == 0 + + verify_temp_file_integrity(temp_file) + temp_file.path + ensure + # Keep temp file open so it can be read by other processes + # Caller is responsible for cleanup + end + + private + + attr_reader :storage_attachment + + def download_to_string retries = 0 file_content = nil @@ -51,13 +108,23 @@ class Imports::SecureFileDownloader raise 'Download completed but no content was received' if file_content.nil? || file_content.empty? - verify_file_integrity(file_content) file_content end - private + def create_temp_file + extension = File.extname(storage_attachment.filename.to_s) + basename = File.basename(storage_attachment.filename.to_s, extension) + Tempfile.new(["#{basename}_#{Time.now.to_i}", extension], binmode: true) + end - attr_reader :storage_attachment + def cleanup_temp_file(temp_file) + return unless temp_file + + temp_file.close unless temp_file.closed? + temp_file.unlink if File.exist?(temp_file.path) + rescue StandardError => e + Rails.logger.warn("Failed to cleanup temp file: #{e.message}") + end def verify_file_integrity(file_content) return if file_content.nil? || file_content.empty? @@ -78,4 +145,26 @@ class Imports::SecureFileDownloader raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}" end + + def verify_temp_file_integrity(temp_file) + return if temp_file.nil? || temp_file.size == 0 + + # Verify file size + expected_size = storage_attachment.blob.byte_size + actual_size = temp_file.size + + if expected_size != actual_size + raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes" + end + + # Verify checksum + expected_checksum = storage_attachment.blob.checksum + temp_file.rewind + actual_checksum = Base64.strict_encode64(Digest::MD5.digest(temp_file.read)) + temp_file.rewind + + return unless expected_checksum != actual_checksum + + raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}" + end end diff --git a/app/services/imports/source_detector.rb b/app/services/imports/source_detector.rb new file mode 100644 index 00000000..a1bfd004 --- /dev/null +++ b/app/services/imports/source_detector.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +class Imports::SourceDetector + class UnknownSourceError < StandardError; end + + DETECTION_RULES = { + google_semantic_history: { + required_keys: ['timelineObjects'], + nested_patterns: [ + ['timelineObjects', 0, 'activitySegment'], + ['timelineObjects', 0, 'placeVisit'] + ] + }, + google_records: { + required_keys: ['locations'], + nested_patterns: [ + ['locations', 0, 'latitudeE7'], + ['locations', 0, 'longitudeE7'] + ] + }, + google_phone_takeout: { + alternative_patterns: [ + # Pattern 1: Object with semanticSegments + { + required_keys: ['semanticSegments'], + nested_patterns: [['semanticSegments', 0, 'startTime']] + }, + # Pattern 2: Object with rawSignals + { + required_keys: ['rawSignals'] + }, + # Pattern 3: Array format with visit/activity objects + { + structure: :array, + nested_patterns: [ + [0, 'visit', 'topCandidate', 'placeLocation'], + [0, 'activity'] + ] + } + ] + }, + geojson: { + required_keys: %w[type features], + required_values: { 'type' => 'FeatureCollection' }, + nested_patterns: [ + ['features', 0, 'type'], + ['features', 0, 'geometry'], + ['features', 0, 'properties'] + ] + }, + owntracks: { + structure: :rec_file_lines, + line_pattern: /"_type":"location"/ + } + }.freeze + + def initialize(file_content, filename = nil, file_path = nil) + @file_content = file_content + @filename = filename + @file_path = file_path + end + + def self.new_from_file_header(file_path) + filename = File.basename(file_path) + + # For detection, read only first 2KB to optimize performance + header_content = File.open(file_path, 'rb') { |f| f.read(2048) } + + new(header_content, filename, file_path) + end + + def detect_source + return :gpx if gpx_file? + return :owntracks if owntracks_file? + + json_data = parse_json + return nil unless json_data + + DETECTION_RULES.each do |format, rules| + next if format == :owntracks # Already handled above + + return format if matches_format?(json_data, rules) + end + + nil + end + + def detect_source! + format = detect_source + raise UnknownSourceError, 'Unable to detect file format' unless format + + format + end + + private + + attr_reader :file_content, :filename, :file_path + + def gpx_file? + return false unless filename + + # Must have .gpx extension AND contain GPX XML structure + return false unless filename.downcase.end_with?('.gpx') + + # Check content for GPX structure + content_to_check = + if file_path && File.exist?(file_path) + # Read first 1KB for GPX detection + File.open(file_path, 'rb') { |f| f.read(1024) } + else + file_content + end + ( + content_to_check.strip.start_with?('= current.length + + current = current[key] + elsif current.is_a?(Hash) + return false unless current.key?(key) + + current = current[key] + else + return false + end + end + + !current.nil? + end +end diff --git a/app/services/imports/watcher.rb b/app/services/imports/watcher.rb index 79e0a59c..6467ec06 100644 --- a/app/services/imports/watcher.rb +++ b/app/services/imports/watcher.rb @@ -70,12 +70,14 @@ class Imports::Watcher end def mime_type(source) - case source.to_sym + case source&.to_sym when :gpx then 'application/xml' when :json, :geojson, :google_phone_takeout, :google_records, :google_semantic_history 'application/json' when :owntracks 'application/octet-stream' + when nil + 'application/octet-stream' # fallback MIME type for nil source else raise UnsupportedSourceError, "Unsupported source: #{source}" end diff --git a/app/services/jobs/create.rb b/app/services/jobs/create.rb index d4176652..114a6cf4 100644 --- a/app/services/jobs/create.rb +++ b/app/services/jobs/create.rb @@ -14,9 +14,9 @@ class Jobs::Create points = case job_name when 'start_reverse_geocoding' - user.tracked_points + user.points when 'continue_reverse_geocoding' - user.tracked_points.not_reverse_geocoded + user.points.not_reverse_geocoded else raise InvalidJobName, 'Invalid job name' end diff --git a/app/services/location_search/geocoding_service.rb b/app/services/location_search/geocoding_service.rb new file mode 100644 index 00000000..c8b42833 --- /dev/null +++ b/app/services/location_search/geocoding_service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module LocationSearch + class GeocodingService + MAX_RESULTS = 10 + + def initialize(query) + @query = query + end + + def search + return [] if query.blank? + + perform_geocoding_search(query) + rescue StandardError => e + Rails.logger.error "Geocoding search failed for query '#{query}': #{e.message}" + [] + end + + def provider_name + Geocoder.config.lookup.to_s.capitalize + end + + private + + attr_reader :query + + def perform_geocoding_search(query) + results = Geocoder.search(query, limit: MAX_RESULTS) + return [] if results.blank? + + normalize_geocoding_results(results) + end + + def normalize_geocoding_results(results) + normalized_results = results.filter_map do |result| + lat = result.latitude.to_f + lon = result.longitude.to_f + + next unless valid_coordinates?(lat, lon) + + { + lat: lat, + lon: lon, + name: result.address&.split(',')&.first || 'Unknown location', + address: result.address || '', + type: result.data&.dig('type') || result.data&.dig('class') || 'unknown', + provider_data: { + osm_id: result.data&.dig('osm_id'), + place_rank: result.data&.dig('place_rank'), + importance: result.data&.dig('importance') + } + } + end + + deduplicate_results(normalized_results) + end + + def deduplicate_results(results) + deduplicated = [] + + results.each do |result| + # Check if there's already a result within 100m + duplicate = deduplicated.find do |existing| + distance = calculate_distance_in_meters( + result[:lat], result[:lon], + existing[:lat], existing[:lon] + ) + distance < 100 # meters + end + + deduplicated << result unless duplicate + end + + deduplicated + end + + def calculate_distance_in_meters(lat1, lon1, lat2, lon2) + # Use Geocoder's distance calculation (same as in Distanceable concern) + distance_km = Geocoder::Calculations.distance_between( + [lat1, lon1], + [lat2, lon2], + units: :km + ) + + # Convert to meters and handle potential nil/invalid results + return 0 unless distance_km.is_a?(Numeric) && distance_km.finite? + + distance_km * 1000 # Convert km to meters + end + + def valid_coordinates?(lat, lon) + lat.between?(-90, 90) && lon.between?(-180, 180) + end + end +end diff --git a/app/services/location_search/point_finder.rb b/app/services/location_search/point_finder.rb new file mode 100644 index 00000000..63cb695d --- /dev/null +++ b/app/services/location_search/point_finder.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module LocationSearch + class PointFinder + def initialize(user, params = {}) + @user = user + @latitude = params[:latitude] + @longitude = params[:longitude] + @limit = params[:limit] || 50 + @date_from = params[:date_from] + @date_to = params[:date_to] + @radius_override = params[:radius_override] + end + + def call + return empty_result unless valid_coordinates? + + location = { + lat: @latitude, + lon: @longitude, + type: 'coordinate_search' + } + + find_matching_points([location]) + end + + private + + def find_matching_points(geocoded_locations) + results = [] + + geocoded_locations.each do |location| + search_radius = @radius_override || determine_search_radius(location[:type]) + + matching_points = spatial_matcher.find_points_near( + @user, + location[:lat], + location[:lon], + search_radius, + date_filter_options + ) + + if matching_points.empty? + wider_search = spatial_matcher.find_points_near( + @user, + location[:lat], + location[:lon], + 1000, # 1km radius for debugging + date_filter_options + ) + + next + end + + visits = result_aggregator.group_points_into_visits(matching_points) + + results << { + place_name: location[:name], + coordinates: [location[:lat], location[:lon]], + address: location[:address], + total_visits: visits.length, + first_visit: visits.first[:date], + last_visit: visits.last[:date], + visits: visits.take(@limit) + } + end + + { + locations: results, + total_locations: results.length, + search_metadata: {} + } + end + + def spatial_matcher + @spatial_matcher ||= LocationSearch::SpatialMatcher.new + end + + def result_aggregator + @result_aggregator ||= LocationSearch::ResultAggregator.new + end + + def date_filter_options + { + date_from: @date_from, + date_to: @date_to + } + end + + def determine_search_radius(location_type) + case location_type.to_s.downcase + when 'shop', 'store', 'retail' + 75 # Small radius for specific shops + when 'restaurant', 'cafe', 'food' + 75 # Small radius for specific restaurants + when 'building', 'house', 'address' + 50 # Very small radius for specific addresses + when 'street', 'road' + 50 # Very small radius for streets + when 'neighbourhood', 'neighborhood', 'district', 'suburb' + 300 # Medium radius for neighborhoods + when 'city', 'town', 'village' + 1000 # Large radius for cities + else + 500 # Default radius for unknown types + end + end + + def valid_coordinates? + @latitude.present? && @longitude.present? && + @latitude.to_f.between?(-90, 90) && @longitude.to_f.between?(-180, 180) + end + + def empty_result + { + locations: [], + total_locations: 0, + search_metadata: {} + } + end + end +end diff --git a/app/services/location_search/result_aggregator.rb b/app/services/location_search/result_aggregator.rb new file mode 100644 index 00000000..1fc607f1 --- /dev/null +++ b/app/services/location_search/result_aggregator.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module LocationSearch + class ResultAggregator + include ActionView::Helpers::TextHelper + + # Time threshold for grouping consecutive points into visits (minutes) + VISIT_TIME_THRESHOLD = 30 + + def group_points_into_visits(points) + return [] if points.empty? + + # Sort points by timestamp to handle unordered input + sorted_points = points.sort_by { |p| p[:timestamp] } + + visits = [] + current_visit_points = [] + + sorted_points.each do |point| + if current_visit_points.empty? || within_visit_threshold?(current_visit_points.last, point) + current_visit_points << point + else + # Finalize current visit and start a new one + visits << create_visit_from_points(current_visit_points) if current_visit_points.any? + current_visit_points = [point] + end + end + + # Don't forget the last visit + visits << create_visit_from_points(current_visit_points) if current_visit_points.any? + + visits.sort_by { |visit| -visit[:timestamp] } # Most recent first + end + + private + + def within_visit_threshold?(previous_point, current_point) + time_diff = (current_point[:timestamp] - previous_point[:timestamp]).abs / 60.0 # minutes + time_diff <= VISIT_TIME_THRESHOLD + end + + def create_visit_from_points(points) + return nil if points.empty? + + # Sort points by timestamp to get chronological order + sorted_points = points.sort_by { |p| p[:timestamp] } + first_point = sorted_points.first + last_point = sorted_points.last + + # Calculate visit duration + duration_minutes = + if sorted_points.length > 1 + ((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round + else + # Single point visit - estimate based on typical stay time + 15 # minutes + end + + # Find the most accurate point (lowest accuracy value means higher precision) + most_accurate_point = points.min_by { |p| p[:accuracy] || 999_999 } + + # Calculate average distance from search center + average_distance = (points.sum { |p| p[:distance_meters] } / points.length).round(2) + + { + timestamp: first_point[:timestamp], + date: first_point[:date], + coordinates: most_accurate_point[:coordinates], + distance_meters: average_distance, + duration_estimate: format_duration(duration_minutes), + points_count: points.length, + accuracy_meters: most_accurate_point[:accuracy], + visit_details: { + start_time: first_point[:date], + end_time: last_point[:date], + duration_minutes: duration_minutes, + city: most_accurate_point[:city], + country: most_accurate_point[:country], + altitude_range: calculate_altitude_range(points) + } + } + end + + def format_duration(minutes) + return "~#{pluralize(minutes, 'minute')}" if minutes < 60 + + hours = minutes / 60 + remaining_minutes = minutes % 60 + + if remaining_minutes.zero? + "~#{pluralize(hours, 'hour')}" + else + "~#{pluralize(hours, 'hour')} #{pluralize(remaining_minutes, 'minute')}" + end + end + + def calculate_altitude_range(points) + altitudes = points.map { |p| p[:altitude] }.compact + return nil if altitudes.empty? + + min_altitude = altitudes.min + max_altitude = altitudes.max + + if min_altitude == max_altitude + "#{min_altitude}m" + else + "#{min_altitude}m - #{max_altitude}m" + end + end + end +end diff --git a/app/services/location_search/spatial_matcher.rb b/app/services/location_search/spatial_matcher.rb new file mode 100644 index 00000000..ccc8c048 --- /dev/null +++ b/app/services/location_search/spatial_matcher.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module LocationSearch + class SpatialMatcher + def initialize + # Using PostGIS for efficient spatial queries + end + + def find_points_near(user, latitude, longitude, radius_meters, date_options = {}) + query_sql, bind_values = build_spatial_query(user, latitude, longitude, radius_meters, date_options) + + # Use sanitize_sql_array to safely execute the parameterized query + safe_query = ActiveRecord::Base.sanitize_sql_array([query_sql] + bind_values) + + + ActiveRecord::Base.connection.exec_query(safe_query) + .map { |row| format_point_result(row) } + .sort_by { |point| point[:timestamp] } + .reverse # Most recent first + end + + private + + def build_spatial_query(user, latitude, longitude, radius_meters, date_options = {}) + date_filter_sql, date_bind_values = build_date_filter(date_options) + + # Build parameterized query with proper SRID using ? placeholders + # Use a CTE to avoid duplicating the point calculation + base_sql = <<~SQL + WITH search_point AS ( + SELECT ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography AS geom + ) + SELECT + p.id, + p.timestamp, + ST_Y(p.lonlat::geometry) as latitude, + ST_X(p.lonlat::geometry) as longitude, + p.city, + p.country, + p.altitude, + p.accuracy, + ST_Distance(p.lonlat, search_point.geom) as distance_meters, + TO_TIMESTAMP(p.timestamp) as recorded_at + FROM points p, search_point + WHERE p.user_id = ? + AND ST_DWithin(p.lonlat, search_point.geom, ?) + #{date_filter_sql} + ORDER BY p.timestamp DESC + SQL + + # Combine bind values: longitude, latitude, user_id, radius, then date filters + bind_values = [ + longitude.to_f, # longitude for search point + latitude.to_f, # latitude for search point + user.id, # user_id + radius_meters.to_f # radius_meters + ] + bind_values.concat(date_bind_values) + + [base_sql, bind_values] + end + + def build_date_filter(date_options) + return ['', []] unless date_options[:date_from] || date_options[:date_to] + + filters = [] + bind_values = [] + + if date_options[:date_from] + timestamp_from = date_options[:date_from].to_time.to_i + filters << "p.timestamp >= ?" + bind_values << timestamp_from + end + + if date_options[:date_to] + # Add one day to include the entire end date + timestamp_to = (date_options[:date_to] + 1.day).to_time.to_i + filters << "p.timestamp < ?" + bind_values << timestamp_to + end + + return ['', []] if filters.empty? + + ["AND #{filters.join(' AND ')}", bind_values] + end + + def format_point_result(row) + { + id: row['id'].to_i, + timestamp: row['timestamp'].to_i, + coordinates: [row['latitude'].to_f, row['longitude'].to_f], + city: row['city'], + country: row['country'], + altitude: row['altitude']&.to_i, + accuracy: row['accuracy']&.to_i, + distance_meters: row['distance_meters'].to_f.round(2), + recorded_at: row['recorded_at'], + date: Time.zone.at(row['timestamp'].to_i).iso8601 + } + end + end +end diff --git a/app/services/maps/bounds_calculator.rb b/app/services/maps/bounds_calculator.rb new file mode 100644 index 00000000..f4be3657 --- /dev/null +++ b/app/services/maps/bounds_calculator.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Maps + class BoundsCalculator + class NoUserFoundError < StandardError; end + class NoDateRangeError < StandardError; end + + def initialize(user:, start_date:, end_date:) + @user = user + @start_date = start_date + @end_date = end_date + end + + def call + validate_inputs! + + start_timestamp = parse_date_parameter(@start_date) + end_timestamp = parse_date_parameter(@end_date) + + point_count = + @user + .points + .where(timestamp: start_timestamp..end_timestamp) + .select(:id) + .count + + return build_no_data_response if point_count.zero? + + bounds_result = execute_bounds_query(start_timestamp, end_timestamp) + build_success_response(bounds_result, point_count) + end + + private + + def validate_inputs! + raise NoUserFoundError, 'No user found' unless @user + raise NoDateRangeError, 'No date range specified' unless @start_date && @end_date + end + + def execute_bounds_query(start_timestamp, end_timestamp) + ActiveRecord::Base.connection.exec_query( + "SELECT ST_YMin(ST_Extent(lonlat::geometry)) as min_lat, + ST_YMax(ST_Extent(lonlat::geometry)) as max_lat, + ST_XMin(ST_Extent(lonlat::geometry)) as min_lng, + ST_XMax(ST_Extent(lonlat::geometry)) as max_lng + FROM points + WHERE user_id = $1 + AND timestamp BETWEEN $2 AND $3", + 'bounds_query', + [@user.id, start_timestamp, end_timestamp] + ).first + end + + def build_success_response(bounds_result, point_count) + { + success: true, + data: { + min_lat: bounds_result['min_lat'].to_f, + max_lat: bounds_result['max_lat'].to_f, + min_lng: bounds_result['min_lng'].to_f, + max_lng: bounds_result['max_lng'].to_f, + point_count: point_count + } + } + end + + def build_no_data_response + { + success: false, + error: 'No data found for the specified date range', + point_count: 0 + } + end + + def parse_date_parameter(param) + case param + when String + if param.match?(/^\d+$/) + param.to_i + else + parsed_time = Time.zone.parse(param) + raise ArgumentError, "Invalid date format: #{param}" if parsed_time.nil? + + parsed_time.to_i + end + when Integer + param + else + param.to_i + end + rescue ArgumentError => e + Rails.logger.error "Invalid date format: #{param} - #{e.message}" + raise ArgumentError, "Invalid date format: #{param}" + end + end +end diff --git a/app/services/maps/hexagon_center_manager.rb b/app/services/maps/hexagon_center_manager.rb new file mode 100644 index 00000000..fdce90fc --- /dev/null +++ b/app/services/maps/hexagon_center_manager.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Maps + class HexagonCenterManager + def initialize(stat:, user:) + @stat = stat + @user = user + end + + def call + return build_response_from_centers if pre_calculated_centers_available? + + nil # No pre-calculated data available + end + + private + + attr_reader :stat, :user + + def pre_calculated_centers_available? + return false if stat&.h3_hex_ids.blank? + + stat.h3_hex_ids.is_a?(Array) && stat.h3_hex_ids.any? + end + + def build_response_from_centers + hex_ids = stat.h3_hex_ids + Rails.logger.debug "Using pre-calculated H3 hex IDs: #{hex_ids.size} hexagons" + + result = build_hexagons_from_h3_ids(hex_ids) + { success: true, data: result, pre_calculated: true } + end + + def recalculate_h3_hex_ids + Stats::HexagonCalculator.new(user.id, stat.year, stat.month).call + end + + def update_stat_with_new_hex_ids(new_hex_ids) + stat.update(h3_hex_ids: new_hex_ids) + result = build_hexagons_from_h3_ids(new_hex_ids) + Rails.logger.debug "Successfully recalculated H3 hex IDs: #{new_hex_ids.size} hexagons" + { success: true, data: result, pre_calculated: true } + end + + def build_hexagons_from_h3_ids(hex_ids) + # Convert stored H3 IDs back to hexagon polygons + # Array format: [[h3_index, point_count, earliest, latest], ...] + hexagon_features = hex_ids.map.with_index do |row, index| + h3_index, count, earliest, latest = row + build_hexagon_feature_from_h3(h3_index, [count, earliest, latest], index) + end + + build_feature_collection(hexagon_features) + end + + def build_hexagon_feature_from_h3(h3_index, data, index) + count, earliest, latest = data + + { + 'type' => 'Feature', + 'id' => index + 1, + 'geometry' => Maps::HexagonPolygonGenerator.new(h3_index:).call, + 'properties' => build_hexagon_properties(index, count, earliest, latest) + } + end + + def build_hexagon_properties(index, count, earliest, latest) + { + 'hex_id' => index + 1, + 'point_count' => count, + 'earliest_point' => earliest ? Time.zone.at(earliest).iso8601 : nil, + 'latest_point' => latest ? Time.zone.at(latest).iso8601 : nil + } + end + + def build_feature_collection(hexagon_features) + { + 'type' => 'FeatureCollection', + 'features' => hexagon_features, + 'metadata' => { + 'count' => hexagon_features.count, + 'user_id' => user.id, + 'pre_calculated' => true + } + } + end + end +end diff --git a/app/services/maps/hexagon_polygon_generator.rb b/app/services/maps/hexagon_polygon_generator.rb new file mode 100644 index 00000000..a493eafe --- /dev/null +++ b/app/services/maps/hexagon_polygon_generator.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Maps + class HexagonPolygonGenerator + def initialize(h3_index:) + @h3_index = h3_index + end + + def call + # Parse H3 index from hex string if needed + index = h3_index.is_a?(String) ? h3_index.to_i(16) : h3_index + + # Get the boundary coordinates for this H3 hexagon + boundary_coordinates = H3.to_boundary(index) + + # Convert to GeoJSON polygon format (lng, lat) + polygon_coordinates = boundary_coordinates.map { [_2, _1] } + + # Close the polygon by adding the first point at the end + polygon_coordinates << polygon_coordinates.first + + { + 'type' => 'Polygon', + 'coordinates' => [polygon_coordinates] + } + end + + private + + attr_reader :h3_index + end +end diff --git a/app/services/maps/hexagon_request_handler.rb b/app/services/maps/hexagon_request_handler.rb new file mode 100644 index 00000000..d2b2f3cb --- /dev/null +++ b/app/services/maps/hexagon_request_handler.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Maps + class HexagonRequestHandler + def initialize(params:, user: nil, stat: nil, start_date: nil, end_date: nil) + @params = params + @user = user + @stat = stat + @start_date = start_date + @end_date = end_date + end + + def call + # For authenticated users, we need to find the matching stat + stat ||= find_matching_stat + + if stat + cached_result = Maps::HexagonCenterManager.new(stat:, user:).call + + return cached_result[:data] if cached_result&.dig(:success) + end + + # No pre-calculated data available - return empty feature collection + Rails.logger.debug 'No pre-calculated hexagon centers available' + empty_feature_collection + end + + private + + attr_reader :params, :user, :stat, :start_date, :end_date + + def find_matching_stat + return unless user && start_date + + # Parse the date to extract year and month + if start_date.is_a?(String) + date = Date.parse(start_date) + elsif start_date.is_a?(Time) + date = start_date.to_date + else + return + end + + # Find the stat for this user, year, and month + user.stats.find_by(year: date.year, month: date.month) + rescue Date::Error + nil + end + + def empty_feature_collection + { + 'type' => 'FeatureCollection', + 'features' => [], + 'metadata' => { + 'hexagon_count' => 0, + 'total_points' => 0, + 'source' => 'pre_calculated' + } + } + end + end +end diff --git a/app/services/own_tracks/importer.rb b/app/services/own_tracks/importer.rb index bc63f5f6..33a6bae4 100644 --- a/app/services/own_tracks/importer.rb +++ b/app/services/own_tracks/importer.rb @@ -2,19 +2,23 @@ class OwnTracks::Importer include Imports::Broadcaster + include Imports::FileLoader - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification + file_content = load_file_content parsed_data = OwnTracks::RecParser.new(file_content).call points_data = parsed_data.map do |point| + next unless point_valid?(point) + OwnTracks::Params.new(point).call.merge( import_id: import.id, user_id: user_id, @@ -29,7 +33,7 @@ class OwnTracks::Importer private def bulk_insert_points(batch) - unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } + unique_batch = batch.compact.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] } # rubocop:disable Rails/SkipsModelValidations Point.upsert_all( @@ -40,6 +44,8 @@ class OwnTracks::Importer ) # rubocop:enable Rails/SkipsModelValidations rescue StandardError => e + ExceptionReporter.call(e, "Failed to bulk insert OwnTracks points for user #{user_id}: #{e.message}") + create_notification("Failed to process OwnTracks data: #{e.message}") end @@ -51,4 +57,10 @@ class OwnTracks::Importer kind: :error ) end + + def point_valid?(point) + point['lat'].present? && + point['lon'].present? && + point['tst'].present? + end end diff --git a/app/services/photos/importer.rb b/app/services/photos/importer.rb index f3ce8fc4..e307b6b1 100644 --- a/app/services/photos/importer.rb +++ b/app/services/photos/importer.rb @@ -2,17 +2,18 @@ class Photos::Importer include Imports::Broadcaster + include Imports::FileLoader include PointValidation - attr_reader :import, :user_id + attr_reader :import, :user_id, :file_path - def initialize(import, user_id) + def initialize(import, user_id, file_path = nil) @import = import @user_id = user_id + @file_path = file_path end def call - file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification - json = Oj.load(file_content) + json = load_json_data json.each.with_index(1) { |point, index| create_point(point, index) } end diff --git a/app/services/photos/thumbnail.rb b/app/services/photos/thumbnail.rb index 4f927aad..c7046a2d 100644 --- a/app/services/photos/thumbnail.rb +++ b/app/services/photos/thumbnail.rb @@ -10,7 +10,8 @@ class Photos::Thumbnail end def call - raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source) + raise ArgumentError, 'Photo source cannot be nil' if source.nil? + unsupported_source_error unless SUPPORTED_SOURCES.include?(source) HTTParty.get(request_url, headers: headers) end diff --git a/app/services/points_limit_exceeded.rb b/app/services/points_limit_exceeded.rb index 2bf8de8a..21cb802a 100644 --- a/app/services/points_limit_exceeded.rb +++ b/app/services/points_limit_exceeded.rb @@ -9,7 +9,7 @@ class PointsLimitExceeded return false if DawarichSettings.self_hosted? Rails.cache.fetch(cache_key, expires_in: 1.day) do - @user.tracked_points.count >= points_limit + @user.points_count >= points_limit end end diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb index 91c2a1d7..311b0c26 100644 --- a/app/services/stats/calculate_month.rb +++ b/app/services/stats/calculate_month.rb @@ -37,9 +37,11 @@ class Stats::CalculateMonth stat.assign_attributes( daily_distance: distance_by_day, distance: distance(distance_by_day), - toponyms: toponyms + toponyms: toponyms, + h3_hex_ids: calculate_h3_hex_ids ) - stat.save + + stat.save! end end @@ -47,7 +49,7 @@ class Stats::CalculateMonth return @points if defined?(@points) @points = user - .tracked_points + .points .without_raw_data .where(timestamp: start_timestamp..end_timestamp) .select(:lonlat, :timestamp) @@ -59,12 +61,13 @@ class Stats::CalculateMonth end def toponyms - toponym_points = user - .tracked_points - .without_raw_data - .where(timestamp: start_timestamp..end_timestamp) - .select(:city, :country_name) - .distinct + toponym_points = + user + .points + .without_raw_data + .where(timestamp: start_timestamp..end_timestamp) + .select(:city, :country_name) + .distinct CountriesAndCities.new(toponym_points).call end @@ -81,4 +84,8 @@ class Stats::CalculateMonth def destroy_month_stats(year, month) Stat.where(year:, month:, user:).destroy_all end + + def calculate_h3_hex_ids + Stats::HexagonCalculator.new(user.id, year, month).call + end end diff --git a/app/services/stats/hexagon_calculator.rb b/app/services/stats/hexagon_calculator.rb new file mode 100644 index 00000000..6005f9a5 --- /dev/null +++ b/app/services/stats/hexagon_calculator.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +class Stats::HexagonCalculator + # H3 Configuration + DEFAULT_H3_RESOLUTION = 8 # Small hexagons for good detail + MAX_HEXAGONS = 10_000 # Maximum number of hexagons to prevent memory issues + + class PostGISError < StandardError; end + + def initialize(user_id, year, month) + @user = User.find(user_id) + @year = year.to_i + @month = month.to_i + end + + def call(h3_resolution: DEFAULT_H3_RESOLUTION) + calculate_h3_hexagon_centers(h3_resolution) + end + + private + + attr_reader :user, :year, :month + + def calculate_h3_hexagon_centers(h3_resolution) + result = calculate_hexagons(h3_resolution) + return [] if result.nil? + + # Convert to array format: [h3_index_string, point_count, earliest_timestamp, latest_timestamp] + result.map do |h3_index_string, data| + [ + h3_index_string, + data[0], # count + data[1], # earliest + data[2] # latest + ] + end + end + + # Unified hexagon calculation method + def calculate_hexagons(h3_resolution) + return nil if points.empty? + + begin + h3_hash = calculate_h3_indexes(points, h3_resolution) + + if h3_hash.empty? + Rails.logger.info "No H3 hex IDs calculated for user #{user.id}, #{year}-#{month} (no data)" + return nil + end + + if h3_hash.size > MAX_HEXAGONS + Rails.logger.warn "Too many hexagons (#{h3_hash.size}), using lower resolution" + # Try with lower resolution (larger hexagons) + lower_resolution = [h3_resolution - 2, 0].max + Rails.logger.info "Recalculating with lower H3 resolution: #{lower_resolution}" + # Create a new instance with lower resolution for recursion + return self.class.new(user.id, year, month).calculate_hexagons(lower_resolution) + end + + Rails.logger.info "Generated #{h3_hash.size} H3 hexagons at resolution #{h3_resolution} for user #{user.id}" + h3_hash + rescue StandardError => e + message = "Failed to calculate H3 hexagon centers: #{e.message}" + ExceptionReporter.call(e, message) if defined?(ExceptionReporter) + raise PostGISError, message + end + end + + def start_timestamp + DateTime.new(year, month, 1).to_i + end + + def end_timestamp + DateTime.new(year, month, -1).to_i # -1 returns last day of month + end + + def points + return @points if defined?(@points) + + @points = user + .points + .without_raw_data + .where(timestamp: start_timestamp..end_timestamp) + .where.not(lonlat: nil) + .select(:lonlat, :timestamp) + .order(timestamp: :asc) + end + + def calculate_h3_indexes(points, h3_resolution) + h3_data = {} + + points.find_each do |point| + # Extract lat/lng from PostGIS point + coordinates = [point.lonlat.y, point.lonlat.x] # [lat, lng] for H3 + + # Get H3 index for this point + h3_index = H3.from_geo_coordinates(coordinates, h3_resolution.clamp(0, 15)) + h3_index_string = h3_index.to_s(16) # Convert to hex string immediately + + # Initialize or update data for this hexagon + if h3_data[h3_index_string] + data = h3_data[h3_index_string] + data[0] += 1 # increment count + data[1] = [data[1], point.timestamp].min # update earliest + data[2] = [data[2], point.timestamp].max # update latest + else + h3_data[h3_index_string] = [1, point.timestamp, point.timestamp] # [count, earliest, latest] + end + end + + h3_data + end +end diff --git a/app/services/tracks/boundary_detector.rb b/app/services/tracks/boundary_detector.rb new file mode 100644 index 00000000..6f88f4a8 --- /dev/null +++ b/app/services/tracks/boundary_detector.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +# Service to detect and resolve tracks that span across multiple time chunks +# Handles merging partial tracks and cleaning up duplicates from parallel processing +class Tracks::BoundaryDetector + include Tracks::Segmentation + include Tracks::TrackBuilder + + attr_reader :user + + def initialize(user) + @user = user + end + + # Main method to resolve cross-chunk tracks + def resolve_cross_chunk_tracks + boundary_candidates = find_boundary_track_candidates + return 0 if boundary_candidates.empty? + + resolved_count = 0 + boundary_candidates.each do |group| + resolved_count += 1 if merge_boundary_tracks(group) + end + + resolved_count + end + + private + + # Find tracks that might span chunk boundaries + def find_boundary_track_candidates + # Get recent tracks that might have boundary issues + recent_tracks = user.tracks + .where('created_at > ?', 1.hour.ago) + .includes(:points) + .order(:start_at) + + return [] if recent_tracks.empty? + + # Group tracks that might be connected + boundary_groups = [] + potential_groups = [] + + recent_tracks.each do |track| + # Look for tracks that end close to where another begins + connected_tracks = find_connected_tracks(track, recent_tracks) + + if connected_tracks.any? + # Create or extend a boundary group + existing_group = potential_groups.find { |group| group.include?(track) } + + if existing_group + existing_group.concat(connected_tracks).uniq! + else + potential_groups << ([track] + connected_tracks).uniq + end + end + end + + # Filter groups to only include legitimate boundary cases + potential_groups.select { |group| valid_boundary_group?(group) } + end + + # Find tracks that might be connected to the given track + def find_connected_tracks(track, all_tracks) + connected = [] + track_end_time = track.end_at.to_i + track_start_time = track.start_at.to_i + + # Look for tracks that start shortly after this one ends (within 30 minutes) + time_window = 30.minutes.to_i + + all_tracks.each do |candidate| + next if candidate.id == track.id + + candidate_start = candidate.start_at.to_i + candidate_end = candidate.end_at.to_i + + # Check if tracks are temporally adjacent + if (candidate_start - track_end_time).abs <= time_window || + (track_start_time - candidate_end).abs <= time_window + + # Check if they're spatially connected + if tracks_spatially_connected?(track, candidate) + connected << candidate + end + end + end + + connected + end + + # Check if two tracks are spatially connected (endpoints are close) + def tracks_spatially_connected?(track1, track2) + return false unless track1.points.exists? && track2.points.exists? + + # Get endpoints of both tracks + track1_start = track1.points.order(:timestamp).first + track1_end = track1.points.order(:timestamp).last + track2_start = track2.points.order(:timestamp).first + track2_end = track2.points.order(:timestamp).last + + # Check various connection scenarios + connection_threshold = distance_threshold_meters + + # Track1 end connects to Track2 start + return true if points_are_close?(track1_end, track2_start, connection_threshold) + + # Track2 end connects to Track1 start + return true if points_are_close?(track2_end, track1_start, connection_threshold) + + # Tracks overlap or are very close + return true if points_are_close?(track1_start, track2_start, connection_threshold) || + points_are_close?(track1_end, track2_end, connection_threshold) + + false + end + + # Check if two points are within the specified distance + def points_are_close?(point1, point2, threshold_meters) + return false unless point1 && point2 + + distance_meters = point1.distance_to_geocoder(point2, :m) + distance_meters <= threshold_meters + end + + # Validate that a group of tracks represents a legitimate boundary case + def valid_boundary_group?(group) + return false if group.size < 2 + + # Check that tracks are sequential in time + sorted_tracks = group.sort_by(&:start_at) + + # Ensure no large time gaps that would indicate separate journeys + max_gap = 1.hour.to_i + + sorted_tracks.each_cons(2) do |track1, track2| + time_gap = track2.start_at.to_i - track1.end_at.to_i + return false if time_gap > max_gap + end + + true + end + + # Merge a group of boundary tracks into a single track + def merge_boundary_tracks(track_group) + return false if track_group.size < 2 + + # Sort tracks by start time + sorted_tracks = track_group.sort_by(&:start_at) + + # Collect all points from all tracks + all_points = [] + sorted_tracks.each do |track| + track_points = track.points.order(:timestamp).to_a + all_points.concat(track_points) + end + + # Remove duplicates and sort by timestamp + unique_points = all_points.uniq(&:id).sort_by(&:timestamp) + + return false if unique_points.size < 2 + + # Calculate merged track distance + merged_distance = Point.calculate_distance_for_array_geocoder(unique_points, :m) + + # Create new merged track + merged_track = create_track_from_points(unique_points, merged_distance) + + if merged_track + # Delete the original boundary tracks + sorted_tracks.each(&:destroy) + + true + else + false + end + end + + # Required by Tracks::Segmentation module + def distance_threshold_meters + @distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i + end + + def time_threshold_minutes + @time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i + end +end diff --git a/app/services/tracks/generator.rb b/app/services/tracks/generator.rb deleted file mode 100644 index be22d021..00000000 --- a/app/services/tracks/generator.rb +++ /dev/null @@ -1,223 +0,0 @@ -# frozen_string_literal: true - -# This service handles both bulk and incremental track generation using a unified -# approach with different modes: -# -# - :bulk - Regenerates all tracks from scratch (replaces existing) -# - :incremental - Processes untracked points up to a specified end time -# - :daily - Processes tracks on a daily basis -# -# Key features: -# - Deterministic results (same algorithm for all modes) -# - Simple incremental processing without buffering complexity -# - Configurable time and distance thresholds from user settings -# - Automatic track statistics calculation -# - Proper handling of edge cases (empty points, incomplete segments) -# -# Usage: -# # Bulk regeneration -# Tracks::Generator.new(user, mode: :bulk).call -# -# # Incremental processing -# Tracks::Generator.new(user, mode: :incremental).call -# -# # Daily processing -# Tracks::Generator.new(user, start_at: Date.current, mode: :daily).call -# -class Tracks::Generator - include Tracks::Segmentation - include Tracks::TrackBuilder - - attr_reader :user, :start_at, :end_at, :mode - - def initialize(user, start_at: nil, end_at: nil, mode: :bulk) - @user = user - @start_at = start_at - @end_at = end_at - @mode = mode.to_sym - end - - def call - clean_existing_tracks if should_clean_tracks? - - start_timestamp, end_timestamp = get_timestamp_range - - Rails.logger.debug "Generator: querying points for user #{user.id} in #{mode} mode" - - segments = Track.get_segments_with_points( - user.id, - start_timestamp, - end_timestamp, - time_threshold_minutes, - distance_threshold_meters, - untracked_only: mode == :incremental - ) - - Rails.logger.debug "Generator: created #{segments.size} segments via SQL" - - tracks_created = 0 - - segments.each do |segment| - track = create_track_from_segment(segment) - tracks_created += 1 if track - end - - Rails.logger.info "Generated #{tracks_created} tracks for user #{user.id} in #{mode} mode" - tracks_created - end - - private - - def should_clean_tracks? - case mode - when :bulk, :daily then true - else false - end - end - - def load_points - case mode - when :bulk then load_bulk_points - when :incremental then load_incremental_points - when :daily then load_daily_points - else - raise ArgumentError, "Unknown mode: #{mode}" - end - end - - def load_bulk_points - scope = user.tracked_points.order(:timestamp) - scope = scope.where(timestamp: timestamp_range) if time_range_defined? - - scope - end - - def load_incremental_points - # For incremental mode, we process untracked points - # If end_at is specified, only process points up to that time - scope = user.tracked_points.where(track_id: nil).order(:timestamp) - scope = scope.where(timestamp: ..end_at.to_i) if end_at.present? - - scope - end - - def load_daily_points - day_range = daily_time_range - - user.tracked_points.where(timestamp: day_range).order(:timestamp) - end - - def create_track_from_segment(segment_data) - points = segment_data[:points] - pre_calculated_distance = segment_data[:pre_calculated_distance] - - Rails.logger.debug "Generator: processing segment with #{points.size} points" - return unless points.size >= 2 - - track = create_track_from_points(points, pre_calculated_distance) - Rails.logger.debug "Generator: created track #{track&.id}" - track - end - - def time_range_defined? - start_at.present? || end_at.present? - end - - def time_range - return nil unless time_range_defined? - - start_time = start_at&.to_i - end_time = end_at&.to_i - - if start_time && end_time - Time.zone.at(start_time)..Time.zone.at(end_time) - elsif start_time - Time.zone.at(start_time).. - elsif end_time - ..Time.zone.at(end_time) - end - end - - def timestamp_range - return nil unless time_range_defined? - - start_time = start_at&.to_i - end_time = end_at&.to_i - - if start_time && end_time - start_time..end_time - elsif start_time - start_time.. - elsif end_time - ..end_time - end - end - - def daily_time_range - day = start_at&.to_date || Date.current - day.beginning_of_day.to_i..day.end_of_day.to_i - end - - def clean_existing_tracks - case mode - when :bulk then clean_bulk_tracks - when :daily then clean_daily_tracks - else - raise ArgumentError, "Unknown mode: #{mode}" - end - end - - def clean_bulk_tracks - scope = user.tracks - scope = scope.where(start_at: time_range) if time_range_defined? - - scope.destroy_all - end - - def clean_daily_tracks - day_range = daily_time_range - range = Time.zone.at(day_range.begin)..Time.zone.at(day_range.end) - - scope = user.tracks.where(start_at: range) - scope.destroy_all - end - - def get_timestamp_range - case mode - when :bulk then bulk_timestamp_range - when :daily then daily_timestamp_range - when :incremental then incremental_timestamp_range - else - raise ArgumentError, "Unknown mode: #{mode}" - end - end - - def bulk_timestamp_range - return [start_at.to_i, end_at.to_i] if start_at && end_at - - first_point = user.tracked_points.order(:timestamp).first - last_point = user.tracked_points.order(:timestamp).last - - [first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i] - end - - def daily_timestamp_range - day = start_at&.to_date || Date.current - [day.beginning_of_day.to_i, day.end_of_day.to_i] - end - - def incremental_timestamp_range - first_point = user.tracked_points.where(track_id: nil).order(:timestamp).first - end_timestamp = end_at ? end_at.to_i : Time.current.to_i - - [first_point&.timestamp || 0, end_timestamp] - end - - def distance_threshold_meters - @distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i - end - - def time_threshold_minutes - @time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i - end -end diff --git a/app/services/tracks/incremental_processor.rb b/app/services/tracks/incremental_processor.rb deleted file mode 100644 index 62c1faed..00000000 --- a/app/services/tracks/incremental_processor.rb +++ /dev/null @@ -1,92 +0,0 @@ -# frozen_string_literal: true - -# This service analyzes new points as they're created and determines whether -# they should trigger incremental track generation based on time and distance -# thresholds defined in user settings. -# -# The key insight is that we should trigger track generation when there's a -# significant gap between the new point and the previous point, indicating -# the end of a journey and the start of a new one. -# -# Process: -# 1. Check if the new point should trigger processing (skip imported points) -# 2. Find the last point before the new point -# 3. Calculate time and distance differences -# 4. If thresholds are exceeded, trigger incremental generation -# 5. Set the end_at time to the previous point's timestamp for track finalization -# -# This ensures tracks are properly finalized when journeys end, not when they start. -# -# Usage: -# # In Point model after_create_commit callback -# Tracks::IncrementalProcessor.new(user, new_point).call -# -class Tracks::IncrementalProcessor - attr_reader :user, :new_point, :previous_point - - def initialize(user, new_point) - @user = user - @new_point = new_point - @previous_point = find_previous_point - end - - def call - return unless should_process? - - start_at = find_start_time - end_at = find_end_time - - Tracks::CreateJob.perform_later(user.id, start_at:, end_at:, mode: :incremental) - end - - private - - def should_process? - return false if new_point.import_id.present? - return true unless previous_point - - exceeds_thresholds?(previous_point, new_point) - end - - def find_previous_point - @previous_point ||= - user.tracked_points - .where('timestamp < ?', new_point.timestamp) - .order(:timestamp) - .last - end - - def find_start_time - user.tracks.order(:end_at).last&.end_at - end - - def find_end_time - previous_point ? Time.zone.at(previous_point.timestamp) : nil - end - - def exceeds_thresholds?(previous_point, current_point) - time_gap = time_difference_minutes(previous_point, current_point) - distance_gap = distance_difference_meters(previous_point, current_point) - - time_exceeded = time_gap >= time_threshold_minutes - distance_exceeded = distance_gap >= distance_threshold_meters - - time_exceeded || distance_exceeded - end - - def time_difference_minutes(point1, point2) - (point2.timestamp - point1.timestamp) / 60.0 - end - - def distance_difference_meters(point1, point2) - point1.distance_to(point2) * 1000 - end - - def time_threshold_minutes - @time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i - end - - def distance_threshold_meters - @distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i - end -end diff --git a/app/services/tracks/parallel_generator.rb b/app/services/tracks/parallel_generator.rb new file mode 100644 index 00000000..ea8c8ac2 --- /dev/null +++ b/app/services/tracks/parallel_generator.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +# Main orchestrator service for parallel track generation +# Coordinates time chunking, job scheduling, and session management +class Tracks::ParallelGenerator + include Tracks::Segmentation + include Tracks::TrackBuilder + + attr_reader :user, :start_at, :end_at, :mode, :chunk_size + + def initialize(user, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day) + @user = user + @start_at = start_at + @end_at = end_at + @mode = mode.to_sym + @chunk_size = chunk_size + end + + def call + clean_bulk_tracks if mode == :bulk + + # Generate time chunks + time_chunks = generate_time_chunks + return 0 if time_chunks.empty? + + # Create session for tracking progress + session = create_generation_session(time_chunks.size) + + # Enqueue chunk processing jobs + enqueue_chunk_jobs(session.session_id, time_chunks) + + # Enqueue boundary resolver job (with delay to let chunks complete) + enqueue_boundary_resolver(session.session_id, time_chunks.size) + + Rails.logger.info "Started parallel track generation for user #{user.id} with #{time_chunks.size} chunks (session: #{session.session_id})" + + session + end + + private + + def generate_time_chunks + chunker = Tracks::TimeChunker.new( + user, + start_at: start_at, + end_at: end_at, + chunk_size: chunk_size + ) + + chunker.call + end + + def create_generation_session(total_chunks) + metadata = { + mode: mode.to_s, + chunk_size: humanize_duration(chunk_size), + start_at: start_at&.iso8601, + end_at: end_at&.iso8601, + user_settings: { + time_threshold_minutes: time_threshold_minutes, + distance_threshold_meters: distance_threshold_meters + } + } + + session_manager = Tracks::SessionManager.create_for_user(user.id, metadata) + session_manager.mark_started(total_chunks) + session_manager + end + + def enqueue_chunk_jobs(session_id, time_chunks) + time_chunks.each do |chunk| + Tracks::TimeChunkProcessorJob.perform_later( + user.id, + session_id, + chunk + ) + end + end + + def enqueue_boundary_resolver(session_id, chunk_count) + # Delay based on estimated processing time (30 seconds per chunk + buffer) + estimated_delay = [chunk_count * 30.seconds, 5.minutes].max + + Tracks::BoundaryResolverJob.set(wait: estimated_delay).perform_later( + user.id, + session_id + ) + end + + def clean_bulk_tracks + if time_range_defined? + user.tracks.where( + '(start_at, end_at) OVERLAPS (?, ?)', + start_at&.in_time_zone, + end_at&.in_time_zone + ).destroy_all + else + user.tracks.destroy_all + end + end + + def time_range_defined? + start_at.present? || end_at.present? + end + + def time_range + return nil unless time_range_defined? + + start_time = start_at&.to_i + end_time = end_at&.to_i + + if start_time && end_time + Time.zone.at(start_time)..Time.zone.at(end_time) + elsif start_time + Time.zone.at(start_time).. + elsif end_time + ..Time.zone.at(end_time) + end + end + + def daily_time_range + day = start_at&.to_date || Date.current + day.beginning_of_day.to_i..day.end_of_day.to_i + end + + def distance_threshold_meters + @distance_threshold_meters ||= user.safe_settings.meters_between_routes.to_i + end + + def time_threshold_minutes + @time_threshold_minutes ||= user.safe_settings.minutes_between_routes.to_i + end + + def humanize_duration(duration) + case duration + when 1.day then '1 day' + when 1.hour then '1 hour' + when 6.hours then '6 hours' + when 12.hours then '12 hours' + when 2.days then '2 days' + when 1.week then '1 week' + else + # Convert seconds to readable format + seconds = duration.to_i + if seconds >= 86_400 # days + days = seconds / 86_400 + "#{days} day#{'s' if days != 1}" + elsif seconds >= 3600 # hours + hours = seconds / 3600 + "#{hours} hour#{'s' if hours != 1}" + elsif seconds >= 60 # minutes + minutes = seconds / 60 + "#{minutes} minute#{'s' if minutes != 1}" + else + "#{seconds} second#{'s' if seconds != 1}" + end + end + end +end diff --git a/app/services/tracks/segmentation.rb b/app/services/tracks/segmentation.rb index e5b4477d..cbc5b471 100644 --- a/app/services/tracks/segmentation.rb +++ b/app/services/tracks/segmentation.rb @@ -21,8 +21,8 @@ # time_threshold_minutes methods. # # Used by: -# - Tracks::Generator for splitting points during track generation -# - Tracks::CreateFromPoints for legacy compatibility +# - Tracks::ParallelGenerator and related jobs for splitting points during parallel track generation +# - Tracks::BoundaryDetector for cross-chunk track merging # # Example usage: # class MyTrackProcessor @@ -64,6 +64,29 @@ module Tracks::Segmentation segments end + # Alternative segmentation using Geocoder (no SQL dependency) + def split_points_into_segments_geocoder(points) + return [] if points.empty? + + segments = [] + current_segment = [] + + points.each do |point| + if should_start_new_segment_geocoder?(point, current_segment.last) + # Finalize current segment if it has enough points + segments << current_segment if current_segment.size >= 2 + current_segment = [point] + else + current_segment << point + end + end + + # Don't forget the last segment + segments << current_segment if current_segment.size >= 2 + + segments + end + def should_start_new_segment?(current_point, previous_point) return false if previous_point.nil? @@ -85,6 +108,28 @@ module Tracks::Segmentation false end + # Alternative segmentation logic using Geocoder (no SQL dependency) + def should_start_new_segment_geocoder?(current_point, previous_point) + return false if previous_point.nil? + + # Check time threshold (convert minutes to seconds) + current_timestamp = current_point.timestamp + previous_timestamp = previous_point.timestamp + + time_diff_seconds = current_timestamp - previous_timestamp + time_threshold_seconds = time_threshold_minutes.to_i * 60 + + return true if time_diff_seconds > time_threshold_seconds + + # Check distance threshold using Geocoder + distance_km = calculate_km_distance_between_points_geocoder(previous_point, current_point) + distance_meters = distance_km * 1000 # Convert km to meters + + return true if distance_meters > distance_threshold_meters + + false + end + def calculate_km_distance_between_points(point1, point2) distance_meters = Point.connection.select_value( 'SELECT ST_Distance(ST_GeomFromEWKT($1)::geography, ST_GeomFromEWKT($2)::geography)', @@ -95,6 +140,22 @@ module Tracks::Segmentation distance_meters.to_f / 1000.0 # Convert meters to kilometers end + # In-memory distance calculation using Geocoder (no SQL dependency) + def calculate_km_distance_between_points_geocoder(point1, point2) + begin + distance = point1.distance_to_geocoder(point2, :km) + + # Validate result + if !distance.finite? || distance < 0 + return 0 + end + + distance + rescue StandardError => e + 0 + end + end + def should_finalize_segment?(segment_points, grace_period_minutes = 5) return false if segment_points.size < 2 diff --git a/app/services/tracks/session_manager.rb b/app/services/tracks/session_manager.rb new file mode 100644 index 00000000..99ad322f --- /dev/null +++ b/app/services/tracks/session_manager.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +# Rails cache-based session management for parallel track generation +# Handles job coordination, progress tracking, and cleanup +class Tracks::SessionManager + CACHE_KEY_PREFIX = 'track_generation' + DEFAULT_TTL = 24.hours + + attr_reader :user_id, :session_id + + def initialize(user_id, session_id = nil) + @user_id = user_id + @session_id = session_id || SecureRandom.uuid + end + + # Create a new session + def create_session(metadata = {}) + session_data = { + 'status' => 'pending', + 'total_chunks' => 0, + 'completed_chunks' => 0, + 'tracks_created' => 0, + 'started_at' => Time.current.iso8601, + 'completed_at' => nil, + 'error_message' => nil, + 'metadata' => metadata.deep_stringify_keys + } + + Rails.cache.write(cache_key, session_data, expires_in: DEFAULT_TTL) + # Initialize counters atomically using Redis SET + Rails.cache.redis.with do |redis| + redis.set(counter_key('completed_chunks'), 0, ex: DEFAULT_TTL.to_i) + redis.set(counter_key('tracks_created'), 0, ex: DEFAULT_TTL.to_i) + end + + self + end + + # Update session data + def update_session(updates) + current_data = get_session_data + return false unless current_data + + updated_data = current_data.merge(updates.deep_stringify_keys) + Rails.cache.write(cache_key, updated_data, expires_in: DEFAULT_TTL) + true + end + + # Get session data + def get_session_data + data = Rails.cache.read(cache_key) + return nil unless data + + # Include current counter values + data['completed_chunks'] = counter_value('completed_chunks') + data['tracks_created'] = counter_value('tracks_created') + data + end + + # Check if session exists + def session_exists? + Rails.cache.exist?(cache_key) + end + + # Mark session as started + def mark_started(total_chunks) + update_session( + status: 'processing', + total_chunks: total_chunks, + started_at: Time.current.iso8601 + ) + end + + # Increment completed chunks + def increment_completed_chunks + return false unless session_exists? + + atomic_increment(counter_key('completed_chunks'), 1) + true + end + + # Increment tracks created + def increment_tracks_created(count = 1) + return false unless session_exists? + + atomic_increment(counter_key('tracks_created'), count) + true + end + + # Mark session as completed + def mark_completed + update_session( + status: 'completed', + completed_at: Time.current.iso8601 + ) + end + + # Mark session as failed + def mark_failed(error_message) + update_session( + status: 'failed', + error_message: error_message, + completed_at: Time.current.iso8601 + ) + end + + # Check if all chunks are completed + def all_chunks_completed? + session_data = get_session_data + return false unless session_data + + completed_chunks = counter_value('completed_chunks') + completed_chunks >= session_data['total_chunks'] + end + + # Get progress percentage + def progress_percentage + session_data = get_session_data + return 0 unless session_data + + total = session_data['total_chunks'] + return 100 if total.zero? + + completed = counter_value('completed_chunks') + (completed.to_f / total * 100).round(2) + end + + # Delete session + def cleanup_session + Rails.cache.delete(cache_key) + Rails.cache.redis.with do |redis| + redis.del(counter_key('completed_chunks'), counter_key('tracks_created')) + end + end + + # Class methods for session management + class << self + # Create session for user + def create_for_user(user_id, metadata = {}) + new(user_id).create_session(metadata) + end + + # Find session by user and session ID + def find_session(user_id, session_id) + manager = new(user_id, session_id) + manager.session_exists? ? manager : nil + end + + # Cleanup expired sessions (automatic with Rails cache TTL) + def cleanup_expired_sessions + # With Rails cache, expired keys are automatically cleaned up + # This method exists for compatibility but is essentially a no-op + true + end + end + + private + + def cache_key + "#{CACHE_KEY_PREFIX}:user:#{user_id}:session:#{session_id}" + end + + def counter_key(field) + "#{cache_key}:#{field}" + end + + def counter_value(field) + Rails.cache.redis.with do |redis| + (redis.get(counter_key(field)) || 0).to_i + end + end + + def atomic_increment(key, amount) + Rails.cache.redis.with do |redis| + redis.incrby(key, amount) + end + end +end diff --git a/app/services/tracks/time_chunker.rb b/app/services/tracks/time_chunker.rb new file mode 100644 index 00000000..56db25dd --- /dev/null +++ b/app/services/tracks/time_chunker.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# Service to split time ranges into processable chunks for parallel track generation +# Handles buffer zones to ensure tracks spanning multiple chunks are properly processed +class Tracks::TimeChunker + attr_reader :user, :start_at, :end_at, :chunk_size, :buffer_size + + def initialize(user, start_at: nil, end_at: nil, chunk_size: 1.day, buffer_size: 6.hours) + @user = user + @start_at = start_at + @end_at = end_at + @chunk_size = chunk_size + @buffer_size = buffer_size + end + + def call + time_range = determine_time_range + return [] if time_range.nil? + + start_time, end_time = time_range + return [] if start_time >= end_time + + chunks = [] + current_time = start_time + + while current_time < end_time + chunk_end = [current_time + chunk_size, end_time].min + + chunk = create_chunk(current_time, chunk_end, start_time, end_time) + chunks << chunk if chunk_has_points?(chunk) + + current_time = chunk_end + end + + chunks + end + + private + + def determine_time_range + case + when start_at && end_at + [start_at.to_time, end_at.to_time] + when start_at + [start_at.to_time, Time.current] + when end_at + first_point_time = user.points.minimum(:timestamp) + return nil unless first_point_time + [Time.at(first_point_time), end_at.to_time] + else + # Get full range from user's points + first_point_time = user.points.minimum(:timestamp) + last_point_time = user.points.maximum(:timestamp) + + return nil unless first_point_time && last_point_time + [Time.at(first_point_time), Time.at(last_point_time)] + end + end + + def create_chunk(chunk_start, chunk_end, global_start, global_end) + # Calculate buffer zones, but don't exceed global boundaries + buffer_start = [chunk_start - buffer_size, global_start].max + buffer_end = [chunk_end + buffer_size, global_end].min + + { + chunk_id: SecureRandom.uuid, + start_timestamp: chunk_start.to_i, + end_timestamp: chunk_end.to_i, + buffer_start_timestamp: buffer_start.to_i, + buffer_end_timestamp: buffer_end.to_i, + start_time: chunk_start, + end_time: chunk_end, + buffer_start_time: buffer_start, + buffer_end_time: buffer_end + } + end + + def chunk_has_points?(chunk) + # Check if there are any points in the buffer range to avoid empty chunks + user.points + .where(timestamp: chunk[:buffer_start_timestamp]..chunk[:buffer_end_timestamp]) + .exists? + end +end diff --git a/app/services/tracks/track_builder.rb b/app/services/tracks/track_builder.rb index 0ccd82b0..2172b762 100644 --- a/app/services/tracks/track_builder.rb +++ b/app/services/tracks/track_builder.rb @@ -25,7 +25,7 @@ # This ensures consistency when users change their distance unit preferences. # # Used by: -# - Tracks::Generator for creating tracks during generation +# - Tracks::ParallelGenerator and related jobs for creating tracks during parallel generation # - Any class that needs to convert point arrays to Track records # # Example usage: @@ -59,7 +59,8 @@ module Tracks::TrackBuilder original_path: build_path(points) ) - track.distance = pre_calculated_distance.round + # TODO: Move trips attrs to columns with more precision and range + track.distance = [[pre_calculated_distance.round, 999_999].min, 0].max track.duration = calculate_duration(points) track.avg_speed = calculate_average_speed(track.distance, track.duration) @@ -99,8 +100,10 @@ module Tracks::TrackBuilder # Speed in meters per second, then convert to km/h for storage speed_mps = distance_in_meters.to_f / duration_seconds + speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h - (speed_mps * 3.6).round(2) # m/s to km/h + # Cap the speed to prevent database precision overflow (max 999999.99) + [speed_kmh, 999_999.99].min end def calculate_elevation_stats(points) @@ -142,6 +145,6 @@ module Tracks::TrackBuilder private def user - raise NotImplementedError, "Including class must implement user method" + raise NotImplementedError, 'Including class must implement user method' end end diff --git a/app/services/users/export_data.rb b/app/services/users/export_data.rb index dbe4f33b..fa7c32b5 100644 --- a/app/services/users/export_data.rb +++ b/app/services/users/export_data.rb @@ -311,18 +311,10 @@ class Users::ExportData private - attr_reader :user - - def export_directory - @export_directory - end - - def files_directory - @files_directory - end + attr_reader :user, :export_directory, :files_directory def calculate_entity_counts - Rails.logger.info "Calculating entity counts for export" + Rails.logger.info 'Calculating entity counts for export' counts = { areas: user.areas.count, @@ -331,7 +323,7 @@ class Users::ExportData trips: user.trips.count, stats: user.stats.count, notifications: user.notifications.count, - points: user.tracked_points.count, + points: user.points_count, visits: user.visits.count, places: user.places.count } @@ -340,15 +332,15 @@ class Users::ExportData counts end - def create_zip_archive(export_directory, zip_file_path) + def create_zip_archive(export_directory, zip_file_path) original_compression = Zip.default_compression Zip.default_compression = Zip::Entry::DEFLATED - Zip::File.open(zip_file_path, Zip::File::CREATE) do |zipfile| + Zip::File.open(zip_file_path, create: true) do |zipfile| Dir.glob(export_directory.join('**', '*')).each do |file| next if File.directory?(file) || file == zip_file_path.to_s - relative_path = file.sub(export_directory.to_s + '/', '') + relative_path = file.sub(%r{#{export_directory}/}, '') zipfile.add(relative_path, file) end diff --git a/app/services/users/import_data.rb b/app/services/users/import_data.rb index 664c27cc..2daff4c2 100644 --- a/app/services/users/import_data.rb +++ b/app/services/users/import_data.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'zip' +require 'oj' # Users::ImportData - Imports complete user data from exported archive # @@ -22,6 +23,9 @@ require 'zip' # Files are restored to their original locations and properly attached to records. class Users::ImportData + STREAM_BATCH_SIZE = 5000 + STREAMED_SECTIONS = %w[places visits points].freeze + def initialize(user, archive_path) @user = user @archive_path = archive_path @@ -46,10 +50,7 @@ class Users::ImportData ActiveRecord::Base.transaction do extract_archive - data = load_json_data - - import_in_correct_order(data) - + process_archive_data create_success_notification @import_stats @@ -71,61 +72,224 @@ class Users::ImportData Zip::File.open(archive_path) do |zip_file| zip_file.each do |entry| - extraction_path = @import_directory.join(entry.name) + next if entry.directory? + + sanitized_name = sanitize_zip_entry_name(entry.name) + next if sanitized_name.nil? + + extraction_path = File.expand_path(File.join(@import_directory, sanitized_name)) + safe_import_dir = File.expand_path(@import_directory) + File::SEPARATOR + unless extraction_path.start_with?(safe_import_dir) || extraction_path == File.expand_path(@import_directory) + Rails.logger.warn "Skipping potentially malicious ZIP entry: #{entry.name} (would extract to #{extraction_path})" + next + end + + Rails.logger.debug "Extracting #{entry.name} to #{extraction_path}" FileUtils.mkdir_p(File.dirname(extraction_path)) - - entry.extract(extraction_path) + entry.extract(sanitized_name, destination_directory: @import_directory) end end end - def load_json_data - json_path = @import_directory.join('data.json') + def sanitize_zip_entry_name(entry_name) + sanitized = entry_name.gsub(%r{^[/\\]+}, '') - unless File.exist?(json_path) - raise StandardError, "Data file not found in archive: data.json" + if sanitized.include?('..') || sanitized.start_with?('/') || sanitized.start_with?('\\') + Rails.logger.warn "Rejecting potentially malicious ZIP entry name: #{entry_name}" + return nil end - JSON.parse(File.read(json_path)) - rescue JSON::ParserError => e - raise StandardError, "Invalid JSON format in data file: #{e.message}" + if Pathname.new(sanitized).absolute? + Rails.logger.warn "Rejecting absolute path in ZIP entry: #{entry_name}" + return nil + end + + sanitized end - def import_in_correct_order(data) + def process_archive_data Rails.logger.info "Starting data import for user: #{user.email}" - if data['counts'] - Rails.logger.info "Expected entity counts from export: #{data['counts']}" + json_path = @import_directory.join('data.json') + unless File.exist?(json_path) + raise StandardError, 'Data file not found in archive: data.json' end - Rails.logger.debug "Available data keys: #{data.keys.inspect}" + initialize_stream_state - import_settings(data['settings']) if data['settings'] - import_areas(data['areas']) if data['areas'] + handler = ::JsonStreamHandler.new(self) + parser = Oj::Parser.new(:saj, handler: handler) - # Import places first to ensure they're available for visits - places_imported = import_places(data['places']) if data['places'] - Rails.logger.info "Places import phase completed: #{places_imported} places imported" - - import_imports(data['imports']) if data['imports'] - import_exports(data['exports']) if data['exports'] - import_trips(data['trips']) if data['trips'] - import_stats(data['stats']) if data['stats'] - import_notifications(data['notifications']) if data['notifications'] - - # Import visits after places to ensure proper place resolution - visits_imported = import_visits(data['visits']) if data['visits'] - Rails.logger.info "Visits import phase completed: #{visits_imported} visits imported" - - import_points(data['points']) if data['points'] - - # Final validation check - if data['counts'] - validate_import_completeness(data['counts']) + File.open(json_path, 'rb') do |io| + parser.load(io) end + finalize_stream_processing + rescue Oj::ParseError => e + raise StandardError, "Invalid JSON format in data file: #{e.message}" + rescue IOError => e + raise StandardError, "Failed to read JSON data: #{e.message}" + end + + def initialize_stream_state + @expected_counts = nil + @places_batch = [] + @stream_writers = {} + @stream_temp_paths = {} + end + + def finalize_stream_processing + flush_places_batch + close_stream_writer(:visits) + close_stream_writer(:points) + + process_visits_stream + process_points_stream + Rails.logger.info "Data import completed. Stats: #{@import_stats}" + + validate_import_completeness(@expected_counts) if @expected_counts.present? + end + + def handle_section(key, value) + case key + when 'counts' + @expected_counts = value if value.is_a?(Hash) + Rails.logger.info "Expected entity counts from export: #{@expected_counts}" if @expected_counts + when 'settings' + import_settings(value) if value.present? + when 'areas' + import_areas(value) + when 'imports' + import_imports(value) + when 'exports' + import_exports(value) + when 'trips' + import_trips(value) + when 'stats' + import_stats_section(value) + when 'notifications' + import_notifications(value) + else + Rails.logger.debug "Unhandled non-stream section #{key}" unless STREAMED_SECTIONS.include?(key) + end + end + + def handle_stream_value(section, value) + case section + when 'places' + queue_place_for_import(value) + when 'visits' + append_to_stream(:visits, value) + when 'points' + append_to_stream(:points, value) + else + Rails.logger.debug "Received stream value for unknown section #{section}" + end + end + + def finish_stream(section) + case section + when 'places' + flush_places_batch + when 'visits' + close_stream_writer(:visits) + when 'points' + close_stream_writer(:points) + end + end + + def queue_place_for_import(place_data) + return unless place_data.is_a?(Hash) + + @places_batch << place_data + + if @places_batch.size >= STREAM_BATCH_SIZE + import_places_batch(@places_batch) + @places_batch.clear + end + end + + def flush_places_batch + return if @places_batch.blank? + + import_places_batch(@places_batch) + @places_batch.clear + end + + def import_places_batch(batch) + Rails.logger.debug "Importing places batch of size #{batch.size}" + places_created = Users::ImportData::Places.new(user, batch.dup).call.to_i + @import_stats[:places_created] += places_created + end + + def append_to_stream(section, value) + return unless value + + writer = stream_writer(section) + writer.puts(Oj.dump(value, mode: :compat)) + end + + def stream_writer(section) + @stream_writers[section] ||= begin + path = stream_temp_path(section) + Rails.logger.debug "Creating stream buffer for #{section} at #{path}" + File.open(path, 'w') + end + end + + def stream_temp_path(section) + @stream_temp_paths[section] ||= @import_directory.join("stream_#{section}.ndjson") + end + + def close_stream_writer(section) + @stream_writers[section]&.close + ensure + @stream_writers.delete(section) + end + + def process_visits_stream + path = @stream_temp_paths[:visits] + return unless path&.exist? + + Rails.logger.info 'Importing visits from streamed buffer' + + batch = [] + File.foreach(path) do |line| + line = line.strip + next if line.blank? + + batch << Oj.load(line) + if batch.size >= STREAM_BATCH_SIZE + import_visits_batch(batch) + batch = [] + end + end + + import_visits_batch(batch) if batch.any? + end + + def import_visits_batch(batch) + visits_created = Users::ImportData::Visits.new(user, batch).call.to_i + @import_stats[:visits_created] += visits_created + end + + def process_points_stream + path = @stream_temp_paths[:points] + return unless path&.exist? + + Rails.logger.info 'Importing points from streamed buffer' + + importer = Users::ImportData::Points.new(user, nil, batch_size: STREAM_BATCH_SIZE) + File.foreach(path) do |line| + line = line.strip + next if line.blank? + + importer.add(Oj.load(line)) + end + + @import_stats[:points_created] = importer.finalize.to_i end def import_settings(settings_data) @@ -136,67 +300,40 @@ class Users::ImportData def import_areas(areas_data) Rails.logger.debug "Importing #{areas_data&.size || 0} areas" - areas_created = Users::ImportData::Areas.new(user, areas_data).call - @import_stats[:areas_created] = areas_created - end - - def import_places(places_data) - Rails.logger.debug "Importing #{places_data&.size || 0} places" - places_created = Users::ImportData::Places.new(user, places_data).call - @import_stats[:places_created] = places_created - places_created + areas_created = Users::ImportData::Areas.new(user, areas_data).call.to_i + @import_stats[:areas_created] += areas_created end def import_imports(imports_data) Rails.logger.debug "Importing #{imports_data&.size || 0} imports" imports_created, files_restored = Users::ImportData::Imports.new(user, imports_data, @import_directory.join('files')).call - @import_stats[:imports_created] = imports_created - @import_stats[:files_restored] += files_restored + @import_stats[:imports_created] += imports_created.to_i + @import_stats[:files_restored] += files_restored.to_i end def import_exports(exports_data) Rails.logger.debug "Importing #{exports_data&.size || 0} exports" exports_created, files_restored = Users::ImportData::Exports.new(user, exports_data, @import_directory.join('files')).call - @import_stats[:exports_created] = exports_created - @import_stats[:files_restored] += files_restored + @import_stats[:exports_created] += exports_created.to_i + @import_stats[:files_restored] += files_restored.to_i end def import_trips(trips_data) Rails.logger.debug "Importing #{trips_data&.size || 0} trips" - trips_created = Users::ImportData::Trips.new(user, trips_data).call - @import_stats[:trips_created] = trips_created + trips_created = Users::ImportData::Trips.new(user, trips_data).call.to_i + @import_stats[:trips_created] += trips_created end - def import_stats(stats_data) + def import_stats_section(stats_data) Rails.logger.debug "Importing #{stats_data&.size || 0} stats" - stats_created = Users::ImportData::Stats.new(user, stats_data).call - @import_stats[:stats_created] = stats_created + stats_created = Users::ImportData::Stats.new(user, stats_data).call.to_i + @import_stats[:stats_created] += stats_created end def import_notifications(notifications_data) Rails.logger.debug "Importing #{notifications_data&.size || 0} notifications" - notifications_created = Users::ImportData::Notifications.new(user, notifications_data).call - @import_stats[:notifications_created] = notifications_created - end - - def import_visits(visits_data) - Rails.logger.debug "Importing #{visits_data&.size || 0} visits" - visits_created = Users::ImportData::Visits.new(user, visits_data).call - @import_stats[:visits_created] = visits_created - visits_created - end - - def import_points(points_data) - Rails.logger.info "About to import #{points_data&.size || 0} points" - - begin - points_created = Users::ImportData::Points.new(user, points_data).call - - @import_stats[:points_created] = points_created - rescue StandardError => e - ExceptionReporter.call(e, 'Points import failed') - @import_stats[:points_created] = 0 - end + notifications_created = Users::ImportData::Notifications.new(user, notifications_data).call.to_i + @import_stats[:notifications_created] += notifications_created end def cleanup_temporary_files(import_directory) @@ -210,15 +347,15 @@ class Users::ImportData def create_success_notification summary = "#{@import_stats[:points_created]} points, " \ - "#{@import_stats[:visits_created]} visits, " \ - "#{@import_stats[:places_created]} places, " \ - "#{@import_stats[:trips_created]} trips, " \ - "#{@import_stats[:areas_created]} areas, " \ - "#{@import_stats[:imports_created]} imports, " \ - "#{@import_stats[:exports_created]} exports, " \ - "#{@import_stats[:stats_created]} stats, " \ - "#{@import_stats[:files_restored]} files restored, " \ - "#{@import_stats[:notifications_created]} notifications" + "#{@import_stats[:visits_created]} visits, " \ + "#{@import_stats[:places_created]} places, " \ + "#{@import_stats[:trips_created]} trips, " \ + "#{@import_stats[:areas_created]} areas, " \ + "#{@import_stats[:imports_created]} imports, " \ + "#{@import_stats[:exports_created]} exports, " \ + "#{@import_stats[:stats_created]} stats, " \ + "#{@import_stats[:files_restored]} files restored, " \ + "#{@import_stats[:notifications_created]} notifications" ::Notifications::Create.new( user: user, @@ -238,7 +375,7 @@ class Users::ImportData end def validate_import_completeness(expected_counts) - Rails.logger.info "Validating import completeness..." + Rails.logger.info 'Validating import completeness...' discrepancies = [] @@ -255,7 +392,7 @@ class Users::ImportData if discrepancies.any? Rails.logger.warn "Import completed with discrepancies: #{discrepancies.join(', ')}" else - Rails.logger.info "Import validation successful - all entities imported correctly" + Rails.logger.info 'Import validation successful - all entities imported correctly' end end end diff --git a/app/services/users/import_data/places.rb b/app/services/users/import_data/places.rb index 6d4ed023..04f9179f 100644 --- a/app/services/users/import_data/places.rb +++ b/app/services/users/import_data/places.rb @@ -1,32 +1,67 @@ # frozen_string_literal: true class Users::ImportData::Places - def initialize(user, places_data) + BATCH_SIZE = 5000 + + def initialize(user, places_data = nil, batch_size: BATCH_SIZE, logger: Rails.logger) @user = user @places_data = places_data + @batch_size = batch_size + @logger = logger + @buffer = [] + @created = 0 end def call - return 0 unless places_data.is_a?(Array) + return 0 unless places_data.respond_to?(:each) - Rails.logger.info "Importing #{places_data.size} places for user: #{user.email}" + logger.info "Importing #{collection_description(places_data)} places for user: #{user.email}" - places_created = 0 - - places_data.each do |place_data| - next unless place_data.is_a?(Hash) - - place = find_or_create_place_for_import(place_data) - places_created += 1 if place&.respond_to?(:previously_new_record?) && place.previously_new_record? + enumerate(places_data) do |place_data| + add(place_data) end - Rails.logger.info "Places import completed. Created: #{places_created}" - places_created + finalize + end + + def add(place_data) + return unless place_data.is_a?(Hash) + + @buffer << place_data + flush_batch if @buffer.size >= batch_size + end + + def finalize + flush_batch + logger.info "Places import completed. Created: #{@created}" + @created end private - attr_reader :user, :places_data + attr_reader :user, :places_data, :batch_size, :logger + + def enumerate(collection, &block) + collection.each(&block) + end + + def collection_description(collection) + return collection.size if collection.respond_to?(:size) + + 'streamed' + end + + def flush_batch + return if @buffer.empty? + + logger.debug "Processing places batch of #{@buffer.size}" + @buffer.each do |place_data| + place = find_or_create_place_for_import(place_data) + @created += 1 if place&.respond_to?(:previously_new_record?) && place.previously_new_record? + end + + @buffer.clear + end def find_or_create_place_for_import(place_data) name = place_data['name'] @@ -34,14 +69,12 @@ class Users::ImportData::Places longitude = place_data['longitude']&.to_f unless name.present? && latitude.present? && longitude.present? - Rails.logger.debug "Skipping place with missing required data: #{place_data.inspect}" + logger.debug "Skipping place with missing required data: #{place_data.inspect}" return nil end - Rails.logger.debug "Processing place for import: #{name} at (#{latitude}, #{longitude})" + logger.debug "Processing place for import: #{name} at (#{latitude}, #{longitude})" - # During import, we prioritize data integrity for the importing user - # First try exact match (name + coordinates) existing_place = Place.where( name: name, latitude: latitude, @@ -49,31 +82,29 @@ class Users::ImportData::Places ).first if existing_place - Rails.logger.debug "Found exact place match: #{name} at (#{latitude}, #{longitude}) -> existing place ID #{existing_place.id}" + logger.debug "Found exact place match: #{name} at (#{latitude}, #{longitude}) -> existing place ID #{existing_place.id}" existing_place.define_singleton_method(:previously_new_record?) { false } return existing_place end - Rails.logger.debug "No exact match found for #{name} at (#{latitude}, #{longitude}). Creating new place." + logger.debug "No exact match found for #{name} at (#{latitude}, #{longitude}). Creating new place." - # If no exact match, create a new place to ensure data integrity - # This prevents data loss during import even if similar places exist place_attributes = place_data.except('created_at', 'updated_at', 'latitude', 'longitude') place_attributes['lonlat'] = "POINT(#{longitude} #{latitude})" place_attributes['latitude'] = latitude place_attributes['longitude'] = longitude place_attributes.delete('user') - Rails.logger.debug "Creating place with attributes: #{place_attributes.inspect}" + logger.debug "Creating place with attributes: #{place_attributes.inspect}" begin place = Place.create!(place_attributes) place.define_singleton_method(:previously_new_record?) { true } - Rails.logger.debug "Created place during import: #{place.name} (ID: #{place.id})" + logger.debug "Created place during import: #{place.name} (ID: #{place.id})" place rescue ActiveRecord::RecordInvalid => e - Rails.logger.error "Failed to create place: #{place_data.inspect}, error: #{e.message}" + logger.error "Failed to create place: #{place_data.inspect}, error: #{e.message}" nil end end diff --git a/app/services/users/import_data/points.rb b/app/services/users/import_data/points.rb index c0c6139d..2d27b8ee 100644 --- a/app/services/users/import_data/points.rb +++ b/app/services/users/import_data/points.rb @@ -1,96 +1,163 @@ # frozen_string_literal: true -class Users::ImportData::Points - BATCH_SIZE = 1000 +require 'time' - def initialize(user, points_data) +class Users::ImportData::Points + BATCH_SIZE = 5000 + + def initialize(user, points_data = nil, batch_size: BATCH_SIZE, logger: Rails.logger) @user = user @points_data = points_data + @batch_size = batch_size + @logger = logger + + @buffer = [] + @total_created = 0 + @processed_count = 0 + @skipped_count = 0 + @preloaded = false + + @imports_lookup = {} + @countries_lookup = {} + @visits_lookup = {} end def call - return 0 unless points_data.is_a?(Array) + return 0 unless points_data.respond_to?(:each) - Rails.logger.info "Importing #{points_data.size} points for user: #{user.email}" - Rails.logger.debug "First point sample: #{points_data.first.inspect}" + logger.info "Importing #{collection_description(points_data)} points for user: #{user.email}" - preload_reference_data - - valid_points = filter_and_prepare_points - - if valid_points.empty? - Rails.logger.warn "No valid points to import after filtering" - Rails.logger.debug "Original points_data size: #{points_data.size}" - return 0 + enumerate(points_data) do |point_data| + add(point_data) end - deduplicated_points = deduplicate_points(valid_points) + finalize + end - Rails.logger.info "Prepared #{deduplicated_points.size} unique valid points (#{points_data.size - deduplicated_points.size} duplicates/invalid skipped)" + # Allows streamed usage by pushing a single point at a time. + def add(point_data) + preload_reference_data unless @preloaded - total_created = bulk_import_points(deduplicated_points) + if valid_point_data?(point_data) + prepared_attributes = prepare_point_attributes(point_data) - Rails.logger.info "Points import completed. Created: #{total_created}" - total_created + if prepared_attributes + @buffer << prepared_attributes + @processed_count += 1 + + flush_batch if @buffer.size >= batch_size + else + @skipped_count += 1 + end + else + @skipped_count += 1 + logger.debug "Skipped point: invalid data - #{point_data.inspect}" + end + end + + def finalize + preload_reference_data unless @preloaded + flush_batch + + logger.info "Points import completed. Created: #{@total_created}. Processed #{@processed_count} valid points, skipped #{@skipped_count}." + @total_created end private - attr_reader :user, :points_data, :imports_lookup, :countries_lookup, :visits_lookup + attr_reader :user, :points_data, :batch_size, :logger, :imports_lookup, :countries_lookup, :visits_lookup + + def enumerate(collection, &block) + collection.each(&block) + end + + def collection_description(collection) + return collection.size if collection.respond_to?(:size) + + 'streamed' + end + + def flush_batch + return if @buffer.empty? + + logger.debug "Processing batch of #{@buffer.size} points" + logger.debug "First point in batch: #{@buffer.first.inspect}" + + normalized_batch = normalize_point_keys(@buffer) + + begin + result = Point.upsert_all( + normalized_batch, + unique_by: %i[lonlat timestamp user_id], + returning: %w[id], + on_duplicate: :skip + ) + + batch_created = result&.count.to_i + @total_created += batch_created + + logger.debug "Processed batch of #{@buffer.size} points, created #{batch_created}, total created: #{@total_created}" + rescue StandardError => e + logger.error "Failed to process point batch: #{e.message}" + logger.error "Batch size: #{@buffer.size}" + logger.error "First point in failed batch: #{@buffer.first.inspect}" + logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}" + ensure + @buffer.clear + end + end def preload_reference_data + return if @preloaded + + logger.debug 'Preloading reference data for points import' + @imports_lookup = {} - user.imports.each do |import| + user.imports.reload.each do |import| string_key = [import.name, import.source, import.created_at.utc.iso8601] integer_key = [import.name, Import.sources[import.source], import.created_at.utc.iso8601] @imports_lookup[string_key] = import @imports_lookup[integer_key] = import end - Rails.logger.debug "Loaded #{user.imports.size} imports with #{@imports_lookup.size} lookup keys" + logger.debug "Loaded #{user.imports.size} imports with #{@imports_lookup.size} lookup keys" @countries_lookup = {} Country.all.each do |country| @countries_lookup[[country.name, country.iso_a2, country.iso_a3]] = country @countries_lookup[country.name] = country end - Rails.logger.debug "Loaded #{Country.count} countries for lookup" + logger.debug "Loaded #{Country.count} countries for lookup" - @visits_lookup = user.visits.index_by { |visit| + @visits_lookup = user.visits.reload.index_by do |visit| [visit.name, visit.started_at.utc.iso8601, visit.ended_at.utc.iso8601] - } - Rails.logger.debug "Loaded #{@visits_lookup.size} visits for lookup" + end + logger.debug "Loaded #{@visits_lookup.size} visits for lookup" + + @preloaded = true end - def filter_and_prepare_points - valid_points = [] - skipped_count = 0 + def normalize_point_keys(points) + all_keys = points.flat_map(&:keys).uniq - points_data.each_with_index do |point_data, index| - next unless point_data.is_a?(Hash) - - unless valid_point_data?(point_data) - skipped_count += 1 - Rails.logger.debug "Skipped point #{index}: invalid data - #{point_data.slice('timestamp', 'longitude', 'latitude', 'lonlat')}" - next + points.map do |point| + all_keys.each_with_object({}) do |key, normalized| + normalized[key] = point[key] end - - prepared_attributes = prepare_point_attributes(point_data) - unless prepared_attributes - skipped_count += 1 - Rails.logger.debug "Skipped point #{index}: failed to prepare attributes" - next - end - - valid_points << prepared_attributes end + end - if skipped_count > 0 - Rails.logger.warn "Skipped #{skipped_count} points with invalid or missing required data" - end + def valid_point_data?(point_data) + return false unless point_data.is_a?(Hash) + return false unless point_data['timestamp'].present? - Rails.logger.debug "Filtered #{valid_points.size} valid points from #{points_data.size} total" - valid_points + has_lonlat = point_data['lonlat'].present? && point_data['lonlat'].is_a?(String) && point_data['lonlat'].start_with?('POINT(') + has_coordinates = point_data['longitude'].present? && point_data['latitude'].present? + + has_lonlat || has_coordinates + rescue StandardError => e + logger.debug "Point validation failed: #{e.message} for data: #{point_data.inspect}" + false end def prepare_point_attributes(point_data) @@ -118,15 +185,14 @@ class Users::ImportData::Points result = attributes.symbolize_keys - Rails.logger.debug "Prepared point attributes: #{result.slice(:lonlat, :timestamp, :import_id, :country_id, :visit_id)}" + logger.debug "Prepared point attributes: #{result.slice(:lonlat, :timestamp, :import_id, :country_id, :visit_id)}" result rescue StandardError => e ExceptionReporter.call(e, 'Failed to prepare point attributes') - nil end - def resolve_import_reference(attributes, import_reference) + def resolve_import_reference(attributes, import_reference) return unless import_reference.is_a?(Hash) created_at = normalize_timestamp_for_lookup(import_reference['created_at']) @@ -140,10 +206,10 @@ class Users::ImportData::Points import = imports_lookup[import_key] if import attributes['import_id'] = import.id - Rails.logger.debug "Resolved import reference: #{import_reference['name']} -> #{import.id}" + logger.debug "Resolved import reference: #{import_reference['name']} -> #{import.id}" else - Rails.logger.debug "Import not found for reference: #{import_reference.inspect}" - Rails.logger.debug "Available imports: #{imports_lookup.keys.inspect}" + logger.debug "Import not found for reference: #{import_reference.inspect}" + logger.debug "Available imports: #{imports_lookup.keys.inspect}" end end @@ -159,14 +225,12 @@ class Users::ImportData::Points if country attributes['country_id'] = country.id - Rails.logger.debug "Resolved country reference: #{country_info['name']} -> #{country.id}" + logger.debug "Resolved country reference: #{country_info['name']} -> #{country.id}" else - Rails.logger.debug "Country not found for: #{country_info.inspect}" + logger.debug "Country not found for: #{country_info.inspect}" end end - - def resolve_visit_reference(attributes, visit_reference) return unless visit_reference.is_a?(Hash) @@ -182,84 +246,19 @@ class Users::ImportData::Points visit = visits_lookup[visit_key] if visit attributes['visit_id'] = visit.id - Rails.logger.debug "Resolved visit reference: #{visit_reference['name']} -> #{visit.id}" + logger.debug "Resolved visit reference: #{visit_reference['name']} -> #{visit.id}" else - Rails.logger.debug "Visit not found for reference: #{visit_reference.inspect}" - Rails.logger.debug "Available visits: #{visits_lookup.keys.inspect}" + logger.debug "Visit not found for reference: #{visit_reference.inspect}" + logger.debug "Available visits: #{visits_lookup.keys.inspect}" end end - def deduplicate_points(points) - points.uniq { |point| [point[:lonlat], point[:timestamp], point[:user_id]] } - end - - def normalize_point_keys(points) - all_keys = points.flat_map(&:keys).uniq - - # Normalize each point to have all keys (with nil for missing ones) - points.map do |point| - normalized = {} - all_keys.each do |key| - normalized[key] = point[key] - end - normalized - end - end - - def bulk_import_points(points) - total_created = 0 - - points.each_slice(BATCH_SIZE) do |batch| - begin - Rails.logger.debug "Processing batch of #{batch.size} points" - Rails.logger.debug "First point in batch: #{batch.first.inspect}" - - normalized_batch = normalize_point_keys(batch) - - result = Point.upsert_all( - normalized_batch, - unique_by: %i[lonlat timestamp user_id], - returning: %w[id], - on_duplicate: :skip - ) - - batch_created = result.count - total_created += batch_created - - Rails.logger.debug "Processed batch of #{batch.size} points, created #{batch_created}, total created: #{total_created}" - - rescue StandardError => e - Rails.logger.error "Failed to process point batch: #{e.message}" - Rails.logger.error "Batch size: #{batch.size}" - Rails.logger.error "First point in failed batch: #{batch.first.inspect}" - Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}" - end - end - - total_created - end - - def valid_point_data?(point_data) - return false unless point_data.is_a?(Hash) - return false unless point_data['timestamp'].present? - - has_lonlat = point_data['lonlat'].present? && point_data['lonlat'].is_a?(String) && point_data['lonlat'].start_with?('POINT(') - has_coordinates = point_data['longitude'].present? && point_data['latitude'].present? - - return false unless has_lonlat || has_coordinates - - true - rescue StandardError => e - Rails.logger.debug "Point validation failed: #{e.message} for data: #{point_data.inspect}" - false - end - def ensure_lonlat_field(attributes, point_data) if attributes['lonlat'].blank? && point_data['longitude'].present? && point_data['latitude'].present? longitude = point_data['longitude'].to_f latitude = point_data['latitude'].to_f attributes['lonlat'] = "POINT(#{longitude} #{latitude})" - Rails.logger.debug "Reconstructed lonlat: #{attributes['lonlat']}" + logger.debug "Reconstructed lonlat: #{attributes['lonlat']}" end end @@ -275,7 +274,7 @@ class Users::ImportData::Points timestamp.to_s end rescue StandardError => e - Rails.logger.debug "Failed to normalize timestamp #{timestamp}: #{e.message}" + logger.debug "Failed to normalize timestamp #{timestamp}: #{e.message}" timestamp.to_s end end diff --git a/app/services/users/import_data/stats.rb b/app/services/users/import_data/stats.rb index f6540c1c..c11ead0a 100644 --- a/app/services/users/import_data/stats.rb +++ b/app/services/users/import_data/stats.rb @@ -60,11 +60,12 @@ class Users::ImportData::Stats end def prepare_stat_attributes(stat_data) - attributes = stat_data.except('created_at', 'updated_at') + attributes = stat_data.except('created_at', 'updated_at', 'sharing_uuid') attributes['user_id'] = user.id attributes['created_at'] = Time.current attributes['updated_at'] = Time.current + attributes['sharing_uuid'] = SecureRandom.uuid attributes.symbolize_keys rescue StandardError => e diff --git a/app/services/visits/create.rb b/app/services/visits/create.rb new file mode 100644 index 00000000..c632d87d --- /dev/null +++ b/app/services/visits/create.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Visits + class Create + attr_reader :user, :params, :errors, :visit + + def initialize(user, params) + @user = user + @params = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params + @visit = nil + @errors = nil + end + + def call + ActiveRecord::Base.transaction do + place = find_or_create_place + return false unless place + + visit = create_visit(place) + visit + end + rescue ActiveRecord::RecordInvalid => e + ExceptionReporter.call(e, "Failed to create visit: #{e.message}") + + @errors = "Failed to create visit: #{e.message}" + + false + rescue StandardError => e + ExceptionReporter.call(e, "Failed to create visit: #{e.message}") + + @errors = "Failed to create visit: #{e.message}" + false + end + + private + + def find_or_create_place + existing_place = find_existing_place + + return existing_place if existing_place + + create_new_place + end + + def find_existing_place + Place.joins("JOIN visits ON places.id = visits.place_id") + .where(visits: { user: user }) + .where( + "ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)", + params[:longitude].to_f, params[:latitude].to_f, 0.001 # approximately 100 meters + ).first + end + + def create_new_place + place_name = params[:name] + lat_f = params[:latitude].to_f + lon_f = params[:longitude].to_f + + place = Place.create!( + name: place_name, + latitude: lat_f, + longitude: lon_f, + lonlat: "POINT(#{lon_f} #{lat_f})", + source: :manual + ) + + place + rescue StandardError => e + ExceptionReporter.call(e, "Failed to create place: #{e.message}") + nil + end + + def create_visit(place) + started_at = Time.zone.parse(params[:started_at]) + ended_at = Time.zone.parse(params[:ended_at]) + duration_minutes = ((ended_at - started_at) / 60).to_i + + @visit = user.visits.create!( + name: params[:name], + place: place, + started_at: started_at, + ended_at: ended_at, + duration: duration_minutes, + status: :confirmed + ) + + @visit + end + end +end diff --git a/app/services/visits/smart_detect.rb b/app/services/visits/smart_detect.rb index 64d66440..828c364d 100644 --- a/app/services/visits/smart_detect.rb +++ b/app/services/visits/smart_detect.rb @@ -13,7 +13,7 @@ module Visits @user = user @start_at = start_at.to_i @end_at = end_at.to_i - @points = user.tracked_points.not_visited + @points = user.points.not_visited .order(timestamp: :asc) .where(timestamp: start_at..end_at) end diff --git a/app/services/visits/suggest.rb b/app/services/visits/suggest.rb index 7aab6b93..b40853fb 100644 --- a/app/services/visits/suggest.rb +++ b/app/services/visits/suggest.rb @@ -6,7 +6,7 @@ class Visits::Suggest def initialize(user, start_at:, end_at:) @start_at = start_at.to_i @end_at = end_at.to_i - @points = user.tracked_points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at) + @points = user.points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at) @user = user end diff --git a/app/views/application/_favicon.html.erb b/app/views/application/_favicon.html.erb index 5eba3e9c..cdfe669c 100644 --- a/app/views/application/_favicon.html.erb +++ b/app/views/application/_favicon.html.erb @@ -1,7 +1,7 @@ - + diff --git a/app/views/devise/registrations/_api_key.html.erb b/app/views/devise/registrations/_api_key.html.erb index aeba5bfd..37daa7fd 100644 --- a/app/views/devise/registrations/_api_key.html.erb +++ b/app/views/devise/registrations/_api_key.html.erb @@ -2,12 +2,10 @@

Use this API key to authenticate your requests.

<%= current_user.api_key %> - <% if ENV['QR_CODE_ENABLED'] == 'true' %> -

- Or you can scan it in your Dawarich iOS app: - <%= api_key_qr_code(current_user) %> -

- <% end %> +

+ Or you can scan it in your Dawarich iOS app: + <%= api_key_qr_code(current_user) %> +

Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %>

diff --git a/app/views/devise/registrations/_points_usage.html.erb b/app/views/devise/registrations/_points_usage.html.erb index c079b93a..68880d0d 100644 --- a/app/views/devise/registrations/_points_usage.html.erb +++ b/app/views/devise/registrations/_points_usage.html.erb @@ -1,6 +1,6 @@

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

- +

diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 23be077a..eba1382b 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -4,11 +4,11 @@

Edit your account!

- <% if current_user.active? %> - <%= render 'devise/registrations/api_key' %> - <% else %> + <%= render 'devise/registrations/api_key' %> + <% if current_user.trial? %> +

Your trial period ends at <%= human_datetime current_user.active_until %>.

- <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success glass' %> to access your API key and start tracking your location. + <%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success glass' %> to continue using Dawarich after your trial ends.

<% end %> <% if !DawarichSettings.self_hosted? %> @@ -82,16 +82,35 @@

Import your data

Upload a ZIP file containing your exported Dawarich data to restore your points, trips, and settings.

- <%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: { turbo: false } do |f| %> + <%= form_with url: import_settings_users_path, method: :post, multipart: true, class: 'space-y-4', data: { + turbo: false, + controller: "user-data-archive-direct-upload", + user_data_archive_direct_upload_url_value: rails_direct_uploads_url, + user_data_archive_direct_upload_user_trial_value: current_user.trial?, + user_data_archive_direct_upload_target: "form" + } do |f| %>
<%= f.label :archive, class: 'label' do %> Select ZIP archive <% end %> - <%= f.file_field :archive, accept: '.zip', required: true, class: 'file-input file-input-bordered w-full' %> + <%= f.file_field :archive, + accept: '.zip', + required: true, + direct_upload: true, + class: 'file-input file-input-bordered w-full', + data: { user_data_archive_direct_upload_target: "input" } %> +
+ File will be uploaded directly to storage. Please be patient during upload. +
<% end %> diff --git a/app/views/devise/registrations/new.html.erb b/app/views/devise/registrations/new.html.erb index 1b0e0d85..707d9cee 100644 --- a/app/views/devise/registrations/new.html.erb +++ b/app/views/devise/registrations/new.html.erb @@ -1,16 +1,38 @@
-

Register now!

-

and take control over your location data.

+ <% if @invitation %> +

Join <%= @invitation.family.name %>!

+

+ You've been invited by <%= @invitation.invited_by.email %> to join their family. + Create your account to accept the invitation and start sharing location data. +

+
+ + + + + Your email (<%= @invitation.email %>) will be used for this account + +
+ <% else %> +

Register now!

+

and take control over your location data.

+ <% end %>
- <%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body ') do |f| %> + <%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %> + <% if @invitation %> + <%= f.hidden_field :invitation_token, value: params[:invitation_token] %> + <% end %> +
<%= f.label :email, class: 'label' do %> Email <% end %> - <%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'input input-bordered' %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", + readonly: @invitation.present?, + class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %>
@@ -18,17 +40,17 @@ Password <% end %> <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum) + (<%= @minimum_password_length %> characters minimum) <% end %>
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %>
<%= f.label :password_confirmation, class: 'label' do %> - Password + Password Confirmation <% end %> <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum) + (<%= @minimum_password_length %> characters minimum) <% end %>
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %>
@@ -38,10 +60,13 @@ <% end %>
- <%= f.submit "Sign up", class: 'btn btn-primary' %> + <%= f.submit (@invitation ? "Create Account & Join Family" : "Sign up"), + class: 'btn btn-primary' %>
- <%= render "devise/shared/links" %> + <% unless @invitation %> + <%= render "devise/shared/links" %> + <% end %> <% end %>
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index d8cb0cde..b471a5cf 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -1,16 +1,29 @@
-

Login now

-

and take control over your location data.

- <% if ENV['DEMO_ENV'] == 'true' %> -

- Demo account: demo@dawarich.app / password: password + <% if @invitation %> +

Sign in to join <%= @invitation.family.name %>!

+

+ You've been invited by <%= @invitation.invited_by.email %> to join their family. + Sign in to your account to accept the invitation.

+
+

+ Don't have an account yet? + <%= link_to "Create one here", new_user_registration_path(invitation_token: @invitation.token), class: "font-semibold underline" %> +

+
+ <% else %> +

Login now

+

and take control over your location data.

<% end %>
- <%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body ') do |f| %> + <%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %> + <% if @invitation %> + <%= hidden_field_tag :invitation_token, params[:invitation_token] %> + <% end %> +
<%= f.label :email, class: 'label' do %> Email @@ -32,10 +45,12 @@ <% end %>
- <%= f.submit "Log in", class: 'btn btn-primary' %> + <%= f.submit (@invitation ? "Sign in & Accept Invitation" : "Log in"), class: 'btn btn-primary' %>
- <%= render "devise/shared/links" %> + <% unless @invitation %> + <%= render "devise/shared/links" %> + <% end %> <% end %>
diff --git a/app/views/families/edit.html.erb b/app/views/families/edit.html.erb new file mode 100644 index 00000000..89451916 --- /dev/null +++ b/app/views/families/edit.html.erb @@ -0,0 +1,99 @@ +
+
+
+
+

+ <%= t('families.edit.title', default: 'Edit Family') %> +

+ <%= link_to family_path, + class: "btn btn-ghost" do %> + <%= t('families.edit.back', default: '← Back to Family') %> + <% end %> +
+ + <%= form_with model: @family, local: true, class: "space-y-6" do |form| %> + <% if @family.errors.any? %> +
+
+

+ <%= t('families.edit.error_title', default: 'There were problems with your submission:') %> +

+
+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %> + <%= form.text_field :name, + class: "input input-bordered w-full", + placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %> +

+ <%= t('families.edit.name_help', default: 'Choose a name that all family members will recognize.') %> +

+
+ +
+

+ <%= t('families.edit.family_info', default: 'Family Information') %> +

+
+
+
+ <%= t('families.edit.creator', default: 'Created by') %> +
+
<%= @family.creator.email %>
+
+
+
+ <%= t('families.edit.created_on', default: 'Created on') %> +
+
<%= @family.created_at.strftime('%B %d, %Y') %>
+
+
+
+ <%= t('families.edit.members_count', default: 'Members') %> +
+
+ <%= pluralize(@family.members.count, 'member') %> +
+
+
+
+ <%= t('families.edit.last_updated', default: 'Last updated') %> +
+
<%= @family.updated_at.strftime('%B %d, %Y') %>
+
+
+
+ +
+
+ <%= form.submit t('families.edit.save_changes', default: 'Save Changes'), + class: "btn btn-primary" %> + <%= link_to family_path, + class: "btn btn-neutral" do %> + <%= t('families.edit.cancel', default: 'Cancel') %> + <% end %> +
+ + <% if policy(@family).destroy? %> + <%= link_to family_path, + method: :delete, + data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, + class: "btn btn-outline btn-error" do %> + <%= icon 'trash-2', class: "inline-block w-4" %> + Delete Family + <% end %> + <% end %> +
+ <% end %> +
+
+
diff --git a/app/views/families/index.html.erb b/app/views/families/index.html.erb new file mode 100644 index 00000000..9a4443e3 --- /dev/null +++ b/app/views/families/index.html.erb @@ -0,0 +1,47 @@ +
+
+
+

+ <%= t('families.index.title', default: 'Family Management') %> +

+

+ <%= t('families.index.description', default: 'Create or join a family to share your location data with loved ones.') %> +

+
+ +
+

+ <%= t('families.index.create_family', default: 'Create Your Family') %> +

+ + <%= form_with model: Family.new, local: true, class: "space-y-4" do |form| %> +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-1" %> + <%= form.text_field :name, + placeholder: t('families.form.name_placeholder', default: 'Enter your family name'), + class: "input input-bordered w-full" %> +
+ +
+ <%= form.submit t('families.form.create', default: 'Create Family'), + class: "btn btn-primary" %> +
+ <% end %> +
+ +
+

+ <%= t('families.index.have_invitation', default: 'Have an invitation?') %> +

+

+ <%= t('families.index.invitation_instructions', default: 'If someone has invited you to join their family, you should have received an email with an invitation link.') %> +

+
+ <%= t('families.index.invitation_help', default: 'Check your email for an invitation link that looks like: ') %> + + <%= "#{request.base_url}/invitations/..." %> + +
+
+
+
\ No newline at end of file diff --git a/app/views/families/new.html.erb b/app/views/families/new.html.erb new file mode 100644 index 00000000..c9350dfd --- /dev/null +++ b/app/views/families/new.html.erb @@ -0,0 +1,66 @@ +
+
+
+

+ <%= t('families.new.title', default: 'Create Your Family') %> +

+

+ <%= t('families.new.description', default: 'Create a family to share your location data with your loved ones.') %> +

+
+ +
+ <%= form_with url: family_path, model: @family, local: true, class: "space-y-6" do |form| %> + <% if @family.errors.any? %> +
+
+

+ <%= t('families.new.error_title', default: 'There were problems with your submission:') %> +

+
+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "label label-text font-medium mb-2" %> + <%= form.text_field :name, + class: "input input-bordered w-full", + placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %> +

+ <%= t('families.new.name_help', default: 'Choose a name that all family members will recognize, like "The Smith Family" or "Our Travel Group".') %> +

+
+ +
+
+

+ <%= t('families.new.what_happens_title', default: 'What happens next?') %> +

+
    +
  • • <%= t('families.new.what_happens_1', default: 'You will become the family owner') %>
  • +
  • • <%= t('families.new.what_happens_2', default: 'You can invite others to join your family') %>
  • +
  • • <%= t('families.new.what_happens_3', default: 'Family members can view shared location data') %>
  • +
  • • <%= t('families.new.what_happens_4', default: 'You can manage family settings and members') %>
  • +
+
+
+ +
+ <%= form.submit t('families.new.create_family', default: 'Create Family'), + class: "btn btn-primary" %> + <%= link_to root_path, + class: "btn btn-ghost" do %> + <%= t('families.new.back', default: '← Back') %> + <% end %> +
+ <% end %> +
+
+
diff --git a/app/views/families/show.html.erb b/app/views/families/show.html.erb new file mode 100644 index 00000000..4ab423f8 --- /dev/null +++ b/app/views/families/show.html.erb @@ -0,0 +1,249 @@ +
+
+ +
+
+
+

<%= @family.name %>

+

+ <%= t('families.show.created_by', default: 'Created by') %> + <%= @family.creator.email %> + <%= t('families.show.on_date', default: 'on') %> + <%= @family.created_at.strftime('%B %d, %Y') %> +

+
+ +
+ <% if policy(@family).update? %> + <%= link_to edit_family_path, + class: "btn btn-outline btn-info" do %> + <%= icon 'square-pen', class: "inline-block w-4" %><%= t('families.show.edit', default: 'Edit') %> + <% end %> + <% end %> + + <% if !current_user.family_owner? && current_user.family_membership %> + <%= link_to family_member_path(current_user.family_membership), + method: :delete, + data: { confirm: 'Are you sure you want to leave this family?', turbo_confirm: 'Are you sure you want to leave this family?' }, + class: "btn btn-outline btm-sm btn-warning" do %> + Leave Family + <% end %> + <% end %> + + <% if policy(@family).destroy? %> + <%= link_to family_path, + method: :delete, + data: { confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' }, + class: "btn btn-outline btm-sm btn-error" do %> + <%= icon 'trash-2', class: "inline-block w-4" %> + Delete + <% end %> + <% end %> +
+
+
+ + +
+
+

+ <%= t('families.show.members_title', default: 'Family Members') %> + (<%= @members.count %>) +

+
+ +
+ <% @members.each do |member| %> +
+
+
+
+ +
+
+
+ + <%= member.email&.first&.upcase || '?' %> + +
+
+ +
+

<%= member.email %>

+
+ <% if member.family_membership.role == 'owner' %> +
+ <%= t('families.show.owner_badge', default: 'Owner') %> +
+ <% else %> + + <%= member.family_membership.role.humanize %> + + <% end %> +
+
+ <%= t('families.show.joined_on', default: 'Joined') %> + <%= member.family_membership.created_at.strftime('%b %d, %Y') %> +
+
+
+
+ + +
+ <% if member == current_user %> + +
+ + Location: + + + + data-location-sharing-toggle-target="checkbox" + data-action="change->location-sharing-toggle#toggle"> + + +
+ +
+ + + <% if member.family_sharing_enabled? && member.family_sharing_expires_at.present? %> +
+ • Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now +
+ <% end %> +
+ + <% else %> + +
+ Location: + <% if member.family_sharing_enabled? %> +
+ + <%= member.family_sharing_duration == 'permanent' ? 'Always' : member.family_sharing_duration&.upcase %> + + <% if member.family_sharing_expires_at.present? %> + + • Expires <%= time_ago_in_words(member.family_sharing_expires_at) %> from now + + <% end %> + <% else %> +
+ Disabled + <% end %> +
+ <% end %> +
+
+
+
+ <% end %> +
+
+ + +
+
+

+ <%= t('families.show.invitations_title', default: 'Pending Invitations') %> + (<%= @pending_invitations.count %>) +

+
+ + <% if @pending_invitations.any? %> +
+ <% @pending_invitations.each do |invitation| %> +
+
+
<%= invitation.email %>
+
+ <%= t('families.show.invited_on', default: 'Invited') %> + <%= invitation.created_at.strftime('%b %d, %Y') %> +
+
+ <%= t('families.show.expires_on', default: 'Expires') %> + <%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %> +
+
+ <% if policy(@family).manage_invitations? %> + <%= link_to family_invitation_path(invitation.token), + method: :delete, + data: { confirm: 'Are you sure you want to cancel this invitation?', turbo_confirm: 'Are you sure you want to cancel this invitation?' }, + class: "btn btn-outline btn-warning btn-sm opacity-70" do %> + Cancel + <% end %> + <% end %> +
+ <% end %> +
+ <% else %> +

+ <%= t('families.show.no_pending_invitations', default: 'No pending invitations') %> +

+ <% end %> + + + <% if policy(@family).invite? && @family.can_add_members? %> +
+

+ <%= t('families.show.invite_member', default: 'Invite New Member') %> +

+ + <%= form_with model: [@family, Family::Invitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %> +
+ <%= form.label :email, t('families.show.email_label', default: 'Email Address'), class: "label label-text font-medium mb-1" %> + <%= form.email_field :email, + placeholder: t('families.show.email_placeholder', default: 'Enter email address'), + class: "input input-bordered w-full" %> +
+ +
+ <%= form.submit t('families.show.send_invitation', default: 'Send Invitation'), + class: "btn btn-primary" %> +
+ <% end %> +
+ <% elsif policy(@family).invite? %> + +
+
+ + + +
+

+ Family at Capacity +

+
+

+ Your family has reached the maximum of <%= @family.class::MAX_MEMBERS %> members (including pending invitations). + Cancel existing invitations or wait for them to expire to invite new members. +

+
+
+
+
+ <% end %> +
+
+
diff --git a/app/views/family/invitations/index.html.erb b/app/views/family/invitations/index.html.erb new file mode 100644 index 00000000..1831ae15 --- /dev/null +++ b/app/views/family/invitations/index.html.erb @@ -0,0 +1,57 @@ +
+
+
+
+

+ <%= t('family_invitations.index.title', default: 'Family Invitations') %> +

+ <%= link_to family_path, + class: "btn btn-neutral" do %> + <%= t('family_invitations.index.back_to_family', default: 'Back to Family') %> + <% end %> +
+ + <% if @pending_invitations.any? %> +
+ <% @pending_invitations.each do |invitation| %> +
+
+
<%= invitation.email %>
+
+ <%= t('family_invitations.index.invited_on', default: 'Invited') %> + <%= invitation.created_at.strftime('%B %d, %Y') %> +
+
+ <%= t('family_invitations.index.expires_on', default: 'Expires') %> + <%= invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') %> +
+
+ +
+ <%= link_to public_invitation_path(invitation.token), + class: "btn btn-ghost btn-sm text-info" do %> + <%= t('family_invitations.index.view_invitation', default: 'View') %> + <% end %> + + <% if policy(@family).manage_invitations? %> + <%= link_to family_invitation_path(invitation.token), + method: :delete, + confirm: t('family_invitations.index.cancel_confirm', default: 'Are you sure you want to cancel this invitation?'), + class: "btn btn-ghost btn-sm text-error" do %> + <%= t('family_invitations.index.cancel', default: 'Cancel') %> + <% end %> + <% end %> +
+
+ <% end %> +
+ <% else %> +
+

+ <%= t('family_invitations.index.no_invitations', default: 'No pending invitations') %> +

+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/family/invitations/show.html.erb b/app/views/family/invitations/show.html.erb new file mode 100644 index 00000000..fac38927 --- /dev/null +++ b/app/views/family/invitations/show.html.erb @@ -0,0 +1,164 @@ +
+
+ +
+
+ <%= icon 'users', class: "h-12 w-12 text-primary-content" %> +
+ +

+ Join <%= @invitation.family.name %>! +

+ +

+ You've been invited by <%= @invitation.invited_by.email %> to join their family. Create your account to accept the invitation and start sharing location data. +

+ +
+ <%= icon 'info', class: "h-5 w-5 mr-2" %> + + Your email (<%= @invitation.email %>) will be used for this account + +
+
+ + +
+

+ What benefits does joining a family bring? +

+ +
+
+
+
+ <%= icon 'map-pin', class: "h-6 w-6 text-info-content" %> +
+
+
+

+ Share Location Data +

+

+ Share your location history with family members and see where they are +

+
+
+ +
+
+
+ <%= icon 'chart-column', class: "h-6 w-6 text-secondary-content" %> +
+
+
+

+ Track your location history +

+

+ Access interactive maps and personal travel statistics +

+
+
+ +
+
+
+ <%= icon 'heart', class: "h-6 w-6 text-success-content" %> +
+
+
+

+ Stay Connected +

+

+ Keep track of your loved ones' travels and adventures in real-time +

+
+
+ +
+
+
+ <%= icon 'shield-check', class: "h-6 w-6 text-warning-content" %> +
+
+
+

+ Full Control & Privacy +

+

+ You control what and how long you share and can leave the family anytime +

+
+
+
+ + +
+

Invitation Details

+
+
+ Family: + <%= @invitation.family.name %> +
+
+ Invited by: + <%= @invitation.invited_by.email %> +
+
+ Your email: + <%= @invitation.email %> +
+
+ Expires: + <%= @invitation.expires_at.strftime('%b %d, %Y') %> +
+
+
+ + +
+ <% if user_signed_in? %> + + <%= link_to accept_family_invitation_path(token: @invitation.token), + method: :post, + class: "btn btn-success btn-lg w-full text-lg shadow-lg" do %> + ✓ Accept Invitation & Join Family + <% end %> + +

+ Logged in as <%= current_user.email %> + · + <%= link_to destroy_user_session_path, method: :delete, class: "link link-info" do %> + Logout + <% end %> +

+ <% else %> + + <%= link_to new_user_registration_path(invitation_token: @invitation.token), + class: "btn btn-primary btn-lg w-full text-lg shadow-lg" do %> + Create Account & Join Family → + <% end %> + +
+

+ Already have an account? +

+ <%= link_to new_user_session_path(invitation_token: @invitation.token), + class: "link link-info font-medium" do %> + Sign in to accept invitation + <% end %> +
+ <% end %> + + +
+

+ Not interested? You can simply close this page. +

+
+
+
+
+
diff --git a/app/views/family_mailer/invitation.html.erb b/app/views/family_mailer/invitation.html.erb new file mode 100644 index 00000000..7a469b53 --- /dev/null +++ b/app/views/family_mailer/invitation.html.erb @@ -0,0 +1,48 @@ +
+
+

You've been invited to join a family!

+ +

Hi there!

+ +

+ <%= @invited_by.email %> has invited you to join their family + "<%= @family.name %>" on Dawarich. +

+ +
+

By joining this family, you'll be able to:

+
    +
  • Share your current location with family members
  • +
  • See the current location of other family members
  • +
  • Stay connected with your loved ones
  • +
  • Control your privacy with full sharing controls
  • +
+
+ +
+ <%= link_to "Accept Invitation", @accept_url, + style: "background-color: #4f46e5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: 600;" %> +
+ +
+

+ ⏰ Important: This invitation will expire in 7 days. +

+
+ +

+ If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation. +

+ +

+ If you didn't expect this invitation, you can safely ignore this email. +

+ +
+ +

+ Best regards,
+ Evgenii from Dawarich +

+
+
diff --git a/app/views/family_mailer/invitation.text.erb b/app/views/family_mailer/invitation.text.erb new file mode 100644 index 00000000..cd9b266c --- /dev/null +++ b/app/views/family_mailer/invitation.text.erb @@ -0,0 +1,22 @@ +You've been invited to join a family! + +Hi there! + +<%= @invited_by.email %> has invited you to join their family "<%= @family.name %>" on Dawarich. + +By joining this family, you'll be able to: +• Share your current location with family members +• See the current location of other family members +• Stay connected with your loved ones +• Control your privacy with full sharing controls + +Accept your invitation here: <%= @accept_url %> + +IMPORTANT: This invitation will expire in 7 days. + +If you don't have a Dawarich account yet, you'll be able to create one when you accept the invitation. + +If you didn't expect this invitation, you can safely ignore this email. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/family_mailer/member_joined.html.erb b/app/views/family_mailer/member_joined.html.erb new file mode 100644 index 00000000..f2429b92 --- /dev/null +++ b/app/views/family_mailer/member_joined.html.erb @@ -0,0 +1,39 @@ +
+
+

🎉 Great news! Someone joined your family!

+ +

Hi <%= @family.owner.email %>!

+ +

+ We're excited to let you know that <%= @user.email %> has just joined your family + "<%= @family.name %>" on Dawarich! +

+ +
+

Now you can:

+
    +
  • See <%= @user.email %>'s current location (if they've enabled sharing)
  • +
  • Stay connected with your growing family
  • +
  • Share your location with <%= @user.email %>
  • +
  • Manage family members and settings from your family page
  • +
+
+ +
+

+ 💡 Tip: You can manage your family members and privacy settings at any time from your family dashboard. +

+
+ +

+ Your family now has <%= @family.member_count %> member<%= @family.member_count == 1 ? '' : 's' %>. +

+ +
+ +

+ Best regards,
+ Evgenii from Dawarich +

+
+
diff --git a/app/views/family_mailer/member_joined.text.erb b/app/views/family_mailer/member_joined.text.erb new file mode 100644 index 00000000..ba840d38 --- /dev/null +++ b/app/views/family_mailer/member_joined.text.erb @@ -0,0 +1,18 @@ +Great news! Someone joined your family! + +Hi <%= @family.owner.email %>! + +We're excited to let you know that <%= @user.email %> has just joined your family "<%= @family.name %>" on Dawarich! + +Now you can: +• See <%= @user.email %>'s current location (if they've enabled sharing) +• Stay connected with your growing family +• Share your location with <%= @user.email %> +• Manage family members and settings from your family page + +TIP: You can manage your family members and privacy settings at any time from your family dashboard. + +Your family now has <%= @family.member_count %> member<%= @family.member_count == 1 ? '' : 's' %>. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/imports/_form.html.erb b/app/views/imports/_form.html.erb index 3f2857fb..16fd3cd5 100644 --- a/app/views/imports/_form.html.erb +++ b/app/views/imports/_form.html.erb @@ -1,71 +1,33 @@ + +
+
+

Supported Import Formats

+
    +
  • ✅ Google Maps: Records.json, Semantic History, Phone Takeout (.json)
  • +
  • ✅ GPX: Track files (.gpx)
  • +
  • ✅ GeoJSON: Feature collections (.json)
  • +
  • ✅ OwnTracks: Recorder files (.rec)
  • +
+
+ File format is automatically detected during upload. +
+ <% if current_user.trial? %> +
+ Trial limitations: Max 5 imports, 10MB per file. +
+ Current imports: <%= current_user.imports.count %>/5 +
+ <% end %> +
+
+ <%= form_with model: import, class: "contents", data: { controller: "direct-upload", direct_upload_url_value: rails_direct_uploads_url, direct_upload_user_trial_value: current_user.trial?, + direct_upload_current_imports_count_value: current_user.imports.count, direct_upload_target: "form" } do |form| %> -
- -
-
-
- -

JSON files from your Takeout/Location History/Semantic Location History/YEAR

-
-
-
-
- -

The Records.json file from your Google Takeout

-
-
-
-
- -

A JSON file you received after your request for Takeout from your mobile device

-
-
-
-
- -

A .REC file you could find in your volumes/owntracks-recorder/store/rec/USER/TOPIC directory

-
-
-
-
- -

A valid GeoJSON file. For example, a file, exported from a Dawarich instance

-
-
-
-
- -

GPX track file

-
-
-
-
-
- +
@@ -55,7 +55,8 @@ <% @imports.each do |import| %> + data-points-total="<%= import.processed %>" + class="hover"> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1036f84d..5d5b880b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -12,6 +12,9 @@ <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <% if ENV['POSTHOG_ENABLED'] == 'true' %> + <%= javascript_include_tag "posthog", "data-turbo-track": "reload" %> + <% end %> <%= javascript_importmap_tags %> <%= javascript_include_tag "https://unpkg.com/protomaps-leaflet@5.0.0/dist/protomaps-leaflet.js" %> diff --git a/app/views/map/_onboarding_modal.html.erb b/app/views/map/_onboarding_modal.html.erb index c1d69b36..27a6e284 100644 --- a/app/views/map/_onboarding_modal.html.erb +++ b/app/views/map/_onboarding_modal.html.erb @@ -1,21 +1,94 @@ <% if user_signed_in? %>
+ data-onboarding-modal-showable-value="true"> -
<% end %> diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 9af06639..85686409 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -9,7 +9,7 @@
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %> - ◀️ + <%= icon 'chevron-left' %> <% end %>
@@ -30,7 +30,7 @@
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn border border-base-300 hover:btn-ghost w-full" do %> - ▶️ + <%= icon 'chevron-right' %> <% end %>
@@ -63,16 +63,20 @@
+ data-timezone="<%= Rails.configuration.time_zone %>" + data-features='<%= @features.to_json.html_safe %>' + data-family-members-features-value='<%= @features.to_json.html_safe %>' + data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
diff --git a/app/views/settings/users/index.html.erb b/app/views/settings/users/index.html.erb index 90cb21e8..c4c6aea0 100644 --- a/app/views/settings/users/index.html.erb +++ b/app/views/settings/users/index.html.erb @@ -24,7 +24,7 @@
Name
<%= link_to import.name, import, class: 'underline hover:no-underline' %> (<%= import.source %>) @@ -72,9 +73,9 @@ <%= human_datetime(import.created_at) %> <% if import.file.present? %> - <%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: import.name %> + <%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %> <% end %> - <%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %> + <%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
- <%= number_with_delimiter user.tracked_points.count %> + <%= number_with_delimiter user.points_count %> <%= human_datetime(user.created_at) %> diff --git a/app/views/shared/_flash.html.erb b/app/views/shared/_flash.html.erb index 08166411..876e8d5e 100644 --- a/app/views/shared/_flash.html.erb +++ b/app/views/shared/_flash.html.erb @@ -1,12 +1,18 @@
<% flash.each do |key, value| %>
-
<%= value %>
- - diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index cf7ac463..360bbca8 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -8,6 +8,23 @@
  • <%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %>
  • <%= link_to 'Tripsα'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %>
  • <%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %>
  • + <% if user_signed_in? && DawarichSettings.family_feature_enabled? %> +
  • + <% if current_user.in_family? %> +
    + <%= link_to family_path, class: "#{active_class?(family_path)} flex items-center space-x-2" do %> + Family +
    + <% end %> +
    + <% else %> + <%= link_to 'Familyα'.html_safe, new_family_path, class: "#{active_class?(new_family_path)}" %> + <% end %> +
  • + <% end %>
  • My data @@ -56,6 +73,24 @@
  • <%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %>
  • <%= link_to 'Tripsα'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %>
  • <%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %>
  • + <% if user_signed_in? && DawarichSettings.family_feature_enabled? %> +
  • + <% if current_user.in_family? %> +
    + <%= link_to family_path, class: "mx-1 flex items-center space-x-2" do %> + Familyα +
    + <% end %> +
    + <% else %> + <%= link_to 'Familyα'.html_safe, new_family_path, class: "mx-1 #{active_class?(new_family_path)}" %> + <% end %> +
  • + <% end %>
  • My data @@ -76,7 +111,17 @@
    <%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %> - <%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining + <% expiry = current_user.active_until %> + <% if expiry.blank? || expiry.past? %> + Trial expired 🥺 + <% else %> + <% days_left = [(expiry.to_date - Time.zone.today).to_i, 0].max %> + + <%= pluralize(days_left, 'day') %> remaining + + <% end %> Subscribe @@ -87,19 +132,8 @@ diff --git a/app/views/stats/_stat.html.erb b/app/views/stats/_stat.html.erb index 14430331..0f2eda1f 100644 --- a/app/views/stats/_stat.html.erb +++ b/app/views/stats/_stat.html.erb @@ -1,30 +1,32 @@ -
    -
    -

    <%= Date::MONTHNAMES[stat.month] %> <%= stat.year %>

    +<%= link_to "#{stat.year}/#{stat.month}", + class: "group block p-6 bg-base-100 hover:bg-base-200/50 rounded-xl border border-base-300 hover:border-primary/40 hover:shadow-lg transition-all duration-200 hover:scale-[1.02]" do %> -
    - <%= link_to "Details", points_path(year: stat.year, month: stat.month), - class: "link link-primary" %> + +
    +

    + <%= "#{icon month_icon(stat)} #{Date::MONTHNAMES[stat.month]} #{stat.year}".html_safe %> +

    +
    + + +
    -
    -
    -

    <%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %>

    + +
    + +
    +
    + <%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %> + <%= current_user.safe_settings.distance_unit %> +
    +
    Total distance
    +
    + + +
    + <%= countries_and_cities_stat_for_month(stat) %>
    - -
    - <%= countries_and_cities_stat_for_month(stat) %> -
    - - <%= area_chart( - stat.daily_distance.map { |day, distance_meters| - [day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round] - }, - height: '200px', - suffix: " #{current_user.safe_settings.distance_unit}", - xtitle: 'Day', - ytitle: 'Distance' - ) %> -
    +<% end %> diff --git a/app/views/stats/_year.html.erb b/app/views/stats/_year.html.erb index fc5fd1e6..e2168ef9 100644 --- a/app/views/stats/_year.html.erb +++ b/app/views/stats/_year.html.erb @@ -10,7 +10,13 @@ height: '200px', suffix: " #{current_user.safe_settings.distance_unit}", xtitle: 'Days', - ytitle: 'Distance' + ytitle: 'Distance', + colors: [ + '#397bb5', '#5A4E9D', '#3B945E', + '#7BC96F', '#FFD54F', '#FFA94D', + '#FF6B6B', '#FF8C42', '#C97E4F', + '#8B4513', '#5A2E2E', '#265d7d' + ] ) %>
    diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb index bd06de8e..7b562e40 100644 --- a/app/views/stats/index.html.erb +++ b/app/views/stats/index.html.erb @@ -1,7 +1,7 @@ <% content_for :title, 'Statistics' %>
    -
    +
    <%= number_with_delimiter(current_user.total_distance.round) %> <%= current_user.safe_settings.distance_unit %> @@ -16,9 +16,12 @@
    Geopoints tracked
    - <% if DawarichSettings.reverse_geocoding_enabled? %> - <%= render 'stats/reverse_geocoding_stats' %> - <% end %> + <% if DawarichSettings.reverse_geocoding_enabled? %> + <%= render 'stats/reverse_geocoding_stats' %> + <% end %> + +
    + All stats data above except for total distance and number of geopoints tracked is being updated daily
    <% if current_user.active? %> @@ -34,15 +37,13 @@ <%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %> <%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
    -
    +
    Last update: <%= human_date(stats.first.updated_at) %> - <%= link_to '🔄', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %> + <%= link_to icon('refresh-ccw'), update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:text-primary' %>

    - <% cache [current_user, 'year_distance_stat', year], skip_digest: true do %> - <%= number_with_delimiter year_distance_stat(year, current_user).round %> <%= current_user.safe_settings.distance_unit %> - <% end %> + <%= number_with_delimiter year_distance_stat(@year_distances[year], current_user).round %> <%= current_user.safe_settings.distance_unit %>

    <% if DawarichSettings.reverse_geocoding_enabled? %>
    @@ -90,7 +91,13 @@ height: '200px', suffix: " #{current_user.safe_settings.distance_unit}", xtitle: 'Days', - ytitle: 'Distance' + ytitle: 'Distance', + colors: [ + '#397bb5', '#5A4E9D', '#3B945E', + '#7BC96F', '#FFD54F', '#FFA94D', + '#FF6B6B', '#FF8C42', '#C97E4F', + '#8B4513', '#5A2E2E', '#265d7d' + ] ) %>
    diff --git a/app/views/stats/month.html.erb b/app/views/stats/month.html.erb new file mode 100644 index 00000000..72d96b80 --- /dev/null +++ b/app/views/stats/month.html.erb @@ -0,0 +1,5 @@ +<% content_for :title, "#{Date::MONTHNAMES[@month]} #{@year} Monthly Digest" %> + +
    + <%= render partial: 'stats/month', locals: { year: @year, month: @month, stat: @stat, previous_stat: @previous_stat } %> +
    diff --git a/app/views/stats/public_month.html.erb b/app/views/stats/public_month.html.erb new file mode 100644 index 00000000..560d285f --- /dev/null +++ b/app/views/stats/public_month.html.erb @@ -0,0 +1,163 @@ +
    + +
    +
    +
    +
    +

    + <%= "#{icon month_icon(@stat)} #{Date::MONTHNAMES[@month]} #{@year}".html_safe %> +

    +

    Monthly Digest

    +
    +
    +
    + +
    +
    +
    Distance traveled
    +
    <%= distance_traveled(@user, @stat) %>
    +
    Total distance for this month
    +
    + +
    +
    Active days
    +
    + <%= active_days(@stat) %> +
    +
    + Days with tracked activity +
    +
    + +
    +
    Countries visited
    +
    + <%= countries_visited(@stat) %> +
    +
    + Different countries +
    +
    +
    + + +
    +
    + +
    +
    +
    +

    + <%= icon 'map' %> Location Hexagons +

    +
    +
    +
    + + +
    +
    + + +
    +
    + +

    Loading hexagons...

    +
    +
    +
    +
    +
    + + +
    +
    +

    + <%= icon 'trending-up' %> Daily Activity +

    +
    + <%= column_chart( + @stat.daily_distance.map { |day, distance_meters| + [day, Stat.convert_distance(distance_meters, 'km').round] + }, + height: '200px', + suffix: " km", + xtitle: 'Day', + ytitle: 'Distance', + colors: [ + '#570df8', '#f000b8', '#ffea00', + '#00d084', '#3abff8', '#ff5724', + '#8e24aa', '#3949ab', '#00897b', + '#d81b60', '#5e35b1', '#039be5', + '#43a047', '#f4511e', '#6d4c41', + '#757575', '#546e7a', '#d32f2f' + ], + library: { + plugins: { + legend: { display: false } + }, + scales: { + x: { + grid: { color: 'rgba(0,0,0,0.1)' } + }, + y: { + grid: { color: 'rgba(0,0,0,0.1)' } + } + } + } + ) %> +
    +
    + Peak day: <%= peak_day(@stat) %> • Quietest week: <%= quietest_week(@stat) %> +
    +
    +
    + + +
    +
    +

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

    +
    + <% @stat.toponyms.each_with_index do |country, index| %> +
    +
    + <%= country['country'] %> + <%= country['cities'].length %> cities +
    + +
    + <% end %> +
    + +
    + +
    + Cities visited: + <% @stat.toponyms.each do |country| %> + <% country['cities'].first(5).each do |city| %> +
    <%= city['city'] %>
    + <% end %> + <% if country['cities'].length > 5 %> +
    +<%= country['cities'].length - 5 %> more
    + <% end %> + <% end %> +
    +
    +
    + + +
    +
    + Powered by Dawarich, your personal memories mapper. +
    +
    +
    diff --git a/app/views/trips/_countries.html.erb b/app/views/trips/_countries.html.erb index ce6f3c7c..c0a6cf53 100644 --- a/app/views/trips/_countries.html.erb +++ b/app/views/trips/_countries.html.erb @@ -11,12 +11,13 @@
    <%= trip_duration(trip) %>
    -
    +
    Countries
    <% if trip.visited_countries.any? %> - <%= trip.visited_countries.join(', ') %> + <%= trip.visited_countries.count %> <% else %> <% end %> @@ -24,3 +25,27 @@
    + + + + + + diff --git a/app/views/users_mailer/explore_features.html.erb b/app/views/users_mailer/explore_features.html.erb index 9d8c64c0..9e70e509 100644 --- a/app/views/users_mailer/explore_features.html.erb +++ b/app/views/users_mailer/explore_features.html.erb @@ -17,12 +17,17 @@

    Explore Dawarich Features

    -

    Hi <%= @user.email %>,

    +

    Hi <%= @user.email %>, this is Evgenii from Dawarich.

    -

    You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.

    +

    You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data.

    Here are some powerful features you might want to explore:

    +
    +

    ✈️ Reliving your travels

    +

    Revisit your past journeys with detailed maps and insights.

    +
    +

    📊 Statistics & Analytics

    View detailed insights about distances traveled and time spent in different locations.

    diff --git a/app/views/users_mailer/explore_features.text.erb b/app/views/users_mailer/explore_features.text.erb index 0ffa8e99..e6b92042 100644 --- a/app/views/users_mailer/explore_features.text.erb +++ b/app/views/users_mailer/explore_features.text.erb @@ -1,11 +1,14 @@ Explore Dawarich Features -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. -You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data. +You're now 2 days into your Dawarich trial! I hope you're enjoying tracking your location data. Here are some powerful features you might want to explore: +✈️ Reliving your travels +Revisit your past journeys with detailed maps and insights. + 📊 Statistics & Analytics View detailed insights about distances traveled and time spent in different locations. diff --git a/app/views/users_mailer/post_trial_reminder_early.html.erb b/app/views/users_mailer/post_trial_reminder_early.html.erb new file mode 100644 index 00000000..c2459943 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_early.html.erb @@ -0,0 +1,49 @@ + + + + + + + +
    +
    +

    🚀 Still Interested in Dawarich?

    +
    +
    +

    Hi <%= @user.email %>, this is Evgenii from Dawarich.

    + +
    +

    Your Dawarich trial ended 2 days ago.

    +
    + +

    I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer!

    + +

    Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off.

    + +

    🌟 What you're missing:

    +
      +
    • Real-time location tracking and analysis
    • +
    • Beautiful, interactive maps with your travel history
    • +
    • Detailed statistics and insights about your journeys
    • +
    • Data export capabilities for your peace of mind
    • +
    + + Subscribe Now + +

    Ready to unlock your location story? Subscribe today and continue your journey with Dawarich!

    + +

    Questions? Just reply to this email – I'm here to help.

    + +

    Best regards,
    + Evgenii from Dawarich

    +
    +
    + + diff --git a/app/views/users_mailer/post_trial_reminder_early.text.erb b/app/views/users_mailer/post_trial_reminder_early.text.erb new file mode 100644 index 00000000..be2edcc8 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_early.text.erb @@ -0,0 +1,24 @@ +🚀 Still Interested in Dawarich? + +Hi <%= @user.email %>, + +Your Dawarich trial ended 2 days ago. + +I noticed you haven't subscribed yet, but I don't want you to miss out on the amazing features Dawarich has to offer! + +Your location data is still safely stored and waiting for you for 365 days. With a subscription, you can pick up exactly where you left off. + +🌟 What you're missing: +- Real-time location tracking and analysis +- Beautiful, interactive maps with your travel history +- Detailed statistics and insights about your journeys +- Data export capabilities for your peace of mind + +Subscribe now: https://my.dawarich.app + +Ready to unlock your location story? Subscribe today and continue your journey with Dawarich! + +Questions? Just reply to this email – I'm here to help. + +Best regards, +Evgenii from Dawarich diff --git a/app/views/users_mailer/post_trial_reminder_late.html.erb b/app/views/users_mailer/post_trial_reminder_late.html.erb new file mode 100644 index 00000000..f347ecb0 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_late.html.erb @@ -0,0 +1,51 @@ + + + + + + + +
    +
    +

    📍 Your Location Data is Waiting

    +
    +
    +

    Hi <%= @user.email %>, this is Evgenii from Dawarich.

    + +
    +

    It's been a week since your Dawarich trial ended.

    +
    + +

    Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time.

    + +

    🗺️ Here's what's waiting for you:

    +
      +
    • All your location data, preserved and ready
    • +
    • Reliving your travels through detailed maps and insights
    • +
    • Privacy-first approach – your data stays yours
    • +
    • Beautiful visualizations of your travel patterns
    • +
    • Regular updates and new features
    • +
    + + Return to Dawarich + +

    This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back.

    + +

    Thank you for giving Dawarich a try. I hope to see you again soon!

    + +

    Safe travels,
    + Evgenii from Dawarich

    + +

    P.S. If you have any questions or need assistance, just hit reply – I'm here to help!

    +
    +
    + + diff --git a/app/views/users_mailer/post_trial_reminder_late.text.erb b/app/views/users_mailer/post_trial_reminder_late.text.erb new file mode 100644 index 00000000..d43db950 --- /dev/null +++ b/app/views/users_mailer/post_trial_reminder_late.text.erb @@ -0,0 +1,26 @@ +📍 Your Location Data is Waiting + +Hi <%= @user.email %>, this is Evgenii from Dawarich. + +It's been a week since your Dawarich trial ended. + +Your location data is still safely stored and patiently waiting for you to return. I understand that choosing the right tool for your location tracking needs is important, and I wanted to reach out one more time. + +🗺️ Here's what's waiting for you: +- All your location data, preserved and ready +- Reliving your travels through detailed maps and insights +- Privacy-first approach – your data stays yours +- Beautiful visualizations of your travel patterns +- Integration with popular location apps and services +- Regular updates and new features + +Return to Dawarich: https://my.dawarich.app + +This is my final reminder about your trial. If Dawarich isn't the right fit for you right now, I completely understand. Your data will remain secure for the next year, and you're always welcome back. + +Thank you for giving Dawarich a try. I hope to see you again soon! + +Safe travels, +Evgenii from Dawarich + +P.S. If you have any questions or need assistance, just hit reply – I'm here to help! diff --git a/app/views/users_mailer/trial_expired.html.erb b/app/views/users_mailer/trial_expired.html.erb index 3294b88b..6407fdbf 100644 --- a/app/views/users_mailer/trial_expired.html.erb +++ b/app/views/users_mailer/trial_expired.html.erb @@ -17,13 +17,13 @@

    🔒 Your Trial Has Expired

    -

    Hi <%= @user.email %>,

    +

    Hi <%= @user.email %>, this is Evgenii from Dawarich.

    Your 7-day Dawarich trial has ended.

    -

    Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.

    +

    Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week.

    Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.

    @@ -40,7 +40,7 @@

    Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!

    -

    We'd love to have you back as a subscriber.

    +

    I'd love to have you back as a subscriber.

    Best regards,
    Evgenii from Dawarich

    diff --git a/app/views/users_mailer/trial_expired.text.erb b/app/views/users_mailer/trial_expired.text.erb index d43178f3..338f0899 100644 --- a/app/views/users_mailer/trial_expired.text.erb +++ b/app/views/users_mailer/trial_expired.text.erb @@ -1,10 +1,10 @@ 🔒 Your Trial Has Expired -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. Your 7-day Dawarich trial has ended. -Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week. +Thank you for trying Dawarich! I hope you enjoyed exploring your location data over the past week. Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich. @@ -19,7 +19,7 @@ Subscribe to continue: https://my.dawarich.app Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off! -We'd love to have you back as a subscriber. +I'd love to have you back as a subscriber. Best regards, Evgenii from Dawarich diff --git a/app/views/users_mailer/trial_expires_soon.html.erb b/app/views/users_mailer/trial_expires_soon.html.erb index c1e5ff6e..a0ac2f3f 100644 --- a/app/views/users_mailer/trial_expires_soon.html.erb +++ b/app/views/users_mailer/trial_expires_soon.html.erb @@ -17,13 +17,13 @@

    ⏰ Your Trial Expires Soon

    -

    Hi <%= @user.email %>,

    +

    Hi <%= @user.email %>, this is Evgenii from Dawarich.

    ⚠️ Important: Your Dawarich trial expires in just 2 days!

    -

    We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.

    +

    I hope you've enjoyed exploring your location data with Dawarich over the past 5 days.

    To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.

    @@ -40,7 +40,7 @@

    Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!

    -

    Questions? Drop us a message at hi@dawarich.app or just reply to this email.

    +

    Questions? Drop me a message at hi@dawarich.app or just reply to this email.

    Best regards,
    Evgenii from Dawarich

    diff --git a/app/views/users_mailer/trial_expires_soon.text.erb b/app/views/users_mailer/trial_expires_soon.text.erb index c5f7352e..6b15ef3a 100644 --- a/app/views/users_mailer/trial_expires_soon.text.erb +++ b/app/views/users_mailer/trial_expires_soon.text.erb @@ -1,10 +1,10 @@ ⏰ Your Trial Expires Soon -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. ⚠️ Important: Your Dawarich trial expires in just 2 days! -We hope you've enjoyed exploring your location data with Dawarich over the past 5 days. +I hope you've enjoyed exploring your location data with Dawarich over the past 5 days. To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan. @@ -19,7 +19,7 @@ Subscribe now: https://my.dawarich.app Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich! -Questions? Drop us a message at hi@dawarich.app +Questions? Drop me a message at hi@dawarich.app or just reply to this email. Best regards, Evgenii from Dawarich diff --git a/app/views/users_mailer/welcome.html.erb b/app/views/users_mailer/welcome.html.erb index 07f80721..c3c34c82 100644 --- a/app/views/users_mailer/welcome.html.erb +++ b/app/views/users_mailer/welcome.html.erb @@ -16,9 +16,9 @@

    Welcome to Dawarich!

    -

    Hi <%= @user.email %>,

    +

    Hi <%= @user.email %>, this is Evgenii from Dawarich.

    -

    Welcome to Dawarich! We're excited to have you on board.

    +

    Welcome to Dawarich! I'm excited to have you on board.

    Your 7-day free trial has started. During this time, you can:

      @@ -30,7 +30,7 @@ Start Exploring Dawarich -

      If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.

      +

      If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email.

      Happy tracking!
      Evgenii from Dawarich

      diff --git a/app/views/users_mailer/welcome.text.erb b/app/views/users_mailer/welcome.text.erb index 8cbf42d2..5870f372 100644 --- a/app/views/users_mailer/welcome.text.erb +++ b/app/views/users_mailer/welcome.text.erb @@ -1,8 +1,8 @@ Welcome to Dawarich! -Hi <%= @user.email %>, +Hi <%= @user.email %>, this is Evgenii from Dawarich. -Welcome to Dawarich! We're excited to have you on board. +Welcome to Dawarich! I'm excited to have you on board. Your 7-day free trial has started. During this time, you can: - Track your location data @@ -12,7 +12,7 @@ Your 7-day free trial has started. During this time, you can: Start exploring Dawarich: https://my.dawarich.app -If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email. +If you have any questions, feel free to drop me a message at hi@dawarich.app or just reply to this email. Happy tracking! Evgenii from Dawarich diff --git a/config/cable.yml b/config/cable.yml index ae88845e..e5713aea 100644 --- a/config/cable.yml +++ b/config/cable.yml @@ -1,11 +1,18 @@ -development: +default: &default adapter: redis url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %> +development: + <<: *default + channel_prefix: dawarich_development + +production: + <<: *default + channel_prefix: dawarich_production + +staging: + <<: *default + channel_prefix: dawarich_staging + test: adapter: test - -production: - adapter: redis - url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %> - channel_prefix: dawarich_production diff --git a/config/database.yml b/config/database.yml index 374dfa53..83027772 100644 --- a/config/database.yml +++ b/config/database.yml @@ -23,4 +23,4 @@ production: staging: <<: *default - database: <%= ENV['DATABASE_NAME'] || 'dawarich_staging' %> + database: <%= ENV['DATABASE_NAME'] || 'dawarich_production' %> diff --git a/config/environments/staging.rb b/config/environments/staging.rb new file mode 100644 index 00000000..ded741dc --- /dev/null +++ b/config/environments/staging.rb @@ -0,0 +1,121 @@ +# 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. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment + # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + + # Enable static file serving from the `/public` folder (turn off if using NGINX/Apache for it). + config.public_file_server.enabled = true + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fallback to assets pipeline if a precompiled asset is missed. + 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" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3 + + config.silence_healthcheck_path = '/api/v1/health' + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = ENV.fetch('APPLICATION_PROTOCOL', 'http').downcase == 'https' + + # Direct logs to STDOUT + config.logger = ActiveSupport::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] + + # 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') + + # Use a different cache store in production. + config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" } + + # Use a real queuing backend for Active Job (and separate queues per environment). + config.active_job.queue_adapter = :sidekiq + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # Skip DNS rebinding protection for the health check endpoint. + config.host_authorization = { exclude: ->(request) { request.path == '/api/v1/health' } } + hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',') + + config.action_mailer.default_url_options = { host: ENV['DOMAIN'] } + config.hosts.concat(hosts) if hosts.present? + + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV['SMTP_SERVER'], + port: ENV['SMTP_PORT'], + domain: ENV['SMTP_DOMAIN'], + user_name: ENV['SMTP_USERNAME'], + password: ENV['SMTP_PASSWORD'], + authentication: 'plain', + enable_starttls: true, + open_timeout: 5, + read_timeout: 5 + } +end diff --git a/config/importmap.rb b/config/importmap.rb index a98b5464..53ca7e84 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -3,6 +3,7 @@ # Pin npm packages by running ./bin/importmap pin_all_from 'app/javascript/channels', under: 'channels' +pin_all_from 'app/javascript/maps', under: 'maps' pin 'application', preload: true pin '@rails/actioncable', to: 'actioncable.esm.js' @@ -22,5 +23,6 @@ pin 'leaflet-draw' # @1.0.4 pin 'notifications_channel', to: 'channels/notifications_channel.js' pin 'points_channel', to: 'channels/points_channel.js' pin 'imports_channel', to: 'channels/imports_channel.js' +pin 'family_locations_channel', to: 'channels/family_locations_channel.js' pin 'trix' pin '@rails/actiontext', to: 'actiontext.esm.js' diff --git a/config/initializers/03_dawarich_settings.rb b/config/initializers/03_dawarich_settings.rb index e7eaf76f..89a49267 100644 --- a/config/initializers/03_dawarich_settings.rb +++ b/config/initializers/03_dawarich_settings.rb @@ -4,7 +4,6 @@ class DawarichSettings BASIC_PAID_PLAN_LIMIT = 10_000_000 # 10 million points class << self - def reverse_geocoding_enabled? @reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? || nominatim_enabled? end @@ -39,5 +38,16 @@ class DawarichSettings def store_geodata? @store_geodata ||= STORE_GEODATA end + + def family_feature_enabled? + @family_feature_enabled ||= self_hosted? + end + + def features + @features ||= { + reverse_geocoding: reverse_geocoding_enabled?, + family: family_feature_enabled? + } + end end end diff --git a/config/initializers/cache_jobs.rb b/config/initializers/cache_jobs.rb index 0cc21349..9e89cbf9 100644 --- a/config/initializers/cache_jobs.rb +++ b/config/initializers/cache_jobs.rb @@ -3,10 +3,8 @@ Rails.application.config.after_initialize do # Only run in server mode and ensure one-time execution with atomic write if defined?(Rails::Server) && Rails.cache.write('cache_jobs_scheduled', true, unless_exist: true) - # Clear the cache Cache::CleaningJob.perform_later - # Preheat the cache Cache::PreheatingJob.perform_later end end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index e93579b9..7b207ed3 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -302,7 +302,7 @@ Devise.setup do |config| # When set to false, does not sign a user in automatically after their password is # changed. Defaults to true, so a user is signed in automatically after changing a password. # config.sign_in_after_change_password = true - config.responder.error_status = :unprocessable_entity + config.responder.error_status = :unprocessable_content config.responder.redirect_status = :see_other if Rails.env.production? && !DawarichSettings.self_hosted? diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index f874a96b..08b69174 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -1,3 +1,4 @@ # frozen_string_literal: true Mime::Type.register 'application/geo+json', :geojson +Mime::Type.register 'application/manifest+json', :webmanifest diff --git a/config/initializers/prometheus.rb b/config/initializers/prometheus.rb index 1a2f38e0..407989d3 100644 --- a/config/initializers/prometheus.rb +++ b/config/initializers/prometheus.rb @@ -1,6 +1,12 @@ # frozen_string_literal: true -if !Rails.env.test? && DawarichSettings.prometheus_exporter_enabled? +# Initialize Prometheus exporter for web processes, but exclude console, rake tasks, and tests +should_initialize = DawarichSettings.prometheus_exporter_enabled? && + !Rails.env.test? && + !defined?(Rails::Console) && + !File.basename($PROGRAM_NAME).include?('rake') + +if should_initialize require 'prometheus_exporter/middleware' require 'prometheus_exporter/instrumentation' diff --git a/config/initializers/rails_icons.rb b/config/initializers/rails_icons.rb new file mode 100644 index 00000000..b0d79e40 --- /dev/null +++ b/config/initializers/rails_icons.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +RailsIcons.configure do |config| + config.default_library = 'lucide' + # config.default_variant = "" # Set a default variant for all libraries + + # Override Lucide defaults + # config.libraries.lucide.default_variant = "" # Set a default variant for Lucide + # config.libraries.lucide.exclude_variants = [] # Exclude specific variants + + # config.libraries.lucide.outline.default.css = "size-6" + # config.libraries.lucide.outline.default.stroke_width = "1.5" + # config.libraries.lucide.outline.default.data = {} +end diff --git a/config/initializers/web_app_manifest.rb b/config/initializers/web_app_manifest.rb index 524a9832..e0b54d66 100644 --- a/config/initializers/web_app_manifest.rb +++ b/config/initializers/web_app_manifest.rb @@ -7,12 +7,15 @@ Rails.application.config.assets.configure do |env| mime_type = 'application/manifest+json' - extensions = ['.webmanifest'] + extensions = ['.webmanifest'] if Sprockets::VERSION.to_i >= 4 - extensions << '.webmanifest.erb' + extensions << '.webmanifest.erb' env.register_preprocessor(mime_type, Sprockets::ERBProcessor) - end - + end + env.register_mime_type(mime_type, extensions: extensions) + + # Register .webmanifest files with the correct MIME type + # env.register_mime_type 'application/manifest+json', extensions: ['.webmanifest'] end diff --git a/config/routes.rb b/config/routes.rb index 0c8026fb..d34aa775 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -56,6 +56,20 @@ Rails.application.routes.draw do resources :places, only: %i[index destroy] resources :exports, only: %i[index create destroy] resources :trips + + # Family management routes (only if feature is enabled) + if DawarichSettings.family_feature_enabled? + resource :family, only: %i[show new create edit update destroy] do + patch :update_location_sharing, on: :member + + resources :invitations, except: %i[edit update], controller: 'family/invitations' + resources :members, only: %i[destroy], controller: 'family/memberships' + end + + get 'invitations/:token', to: 'family/invitations#show', as: :public_invitation + post 'family/memberships', to: 'family/memberships#create', as: :accept_family_invitation + end + resources :points, only: %i[index] do collection do delete :bulk_destroy @@ -70,22 +84,27 @@ Rails.application.routes.draw do end end get 'stats/:year', to: 'stats#show', constraints: { year: /\d{4}/ } + get 'stats/:year/:month', to: 'stats#month', constraints: { year: /\d{4}/, month: /(0?[1-9]|1[0-2])/ } put 'stats/:year/:month/update', to: 'stats#update', as: :update_year_month_stats, constraints: { year: /\d{4}/, month: /\d{1,2}|all/ } + get 'shared/month/:uuid', to: 'shared/stats#show', as: :shared_stat + + # Sharing management endpoint (requires auth) + patch 'stats/:year/:month/sharing', + to: 'shared/stats#update', + as: :sharing_stats, + constraints: { year: /\d{4}/, month: /\d{1,2}/ } root to: 'home#index' - if SELF_HOSTED - devise_for :users, skip: [:registrations] - as :user do - get 'users/edit' => 'devise/registrations#edit', :as => 'edit_user_registration' - put 'users' => 'devise/registrations#update', :as => 'user_registration' - end - else - devise_for :users - end + get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success + + devise_for :users, controllers: { + registrations: 'users/registrations', + sessions: 'users/sessions' + } resources :metrics, only: [:index] @@ -100,8 +119,13 @@ Rails.application.routes.draw do get 'users/me', to: 'users#me' resources :areas, only: %i[index create update destroy] + resources :locations, only: %i[index] do + collection do + get 'suggestions' + end + end resources :points, only: %i[index create update destroy] - resources :visits, only: %i[index update] do + resources :visits, only: %i[index create update destroy] do get 'possible_places', to: 'visits/possible_places#index', on: :member collection do post 'merge', to: 'visits#merge' @@ -135,6 +159,17 @@ Rails.application.routes.draw do namespace :maps do resources :tile_usage, only: [:create] + resources :hexagons, only: [:index] do + collection do + get :bounds + end + end + end + + resources :families, only: [] do + collection do + get :locations + end end post 'subscriptions/callback', to: 'subscriptions#callback' diff --git a/config/schedule.yml b/config/schedule.yml index 0dc3c9e8..ae920927 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -30,12 +30,22 @@ cache_preheating_job: class: "Cache::PreheatingJob" queue: default -# tracks_cleanup_job: -# cron: "0 2 * * 0" # every Sunday at 02:00 -# class: "Tracks::CleanupJob" -# queue: tracks - place_name_fetching_job: cron: "30 0 * * *" # every day at 00:30 class: "Places::BulkNameFetchingJob" queue: places + +daily_track_generation_job: + cron: "0 */4 * * *" # every 4 hours + class: "Tracks::DailyGenerationJob" + queue: tracks + +nightly_reverse_geocoding_job: + cron: "15 1 * * *" # every day at 01:15 + class: "Points::NightlyReverseGeocodingJob" + queue: reverse_geocoding + +nightly_family_invitations_cleanup_job: + cron: "30 2 * * *" # every day at 02:30 + class: "Family::Invitations::CleanupJob" + queue: family diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 780bbc1c..5f2e133e 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -5,6 +5,7 @@ - points - default - mailers + - families - imports - exports - stats diff --git a/db/data/20250704185707_create_tracks_from_points.rb b/db/data/20250704185707_create_tracks_from_points.rb index 7d5cffb5..4860841f 100644 --- a/db/data/20250704185707_create_tracks_from_points.rb +++ b/db/data/20250704185707_create_tracks_from_points.rb @@ -2,33 +2,9 @@ class CreateTracksFromPoints < ActiveRecord::Migration[8.0] def up - puts "Starting bulk track creation for all users..." + # this data migration used to create tracks from existing points. It was deprecated - total_users = User.count - processed_users = 0 - - User.find_each do |user| - points_count = user.tracked_points.count - - if points_count > 0 - puts "Enqueuing track creation for user #{user.id} (#{points_count} points)" - - # Use explicit parameters for bulk historical processing: - # - No time limits (start_at: nil, end_at: nil) = process ALL historical data - Tracks::CreateJob.perform_later( - user.id, - start_at: nil, - end_at: nil, - mode: :bulk - ) - - processed_users += 1 - else - puts "Skipping user #{user.id} (no tracked points)" - end - end - - puts "Enqueued track creation jobs for #{processed_users}/#{total_users} users" + nil end def down diff --git a/db/data/20250709195003_recalculate_trips_distance.rb b/db/data/20250709195003_recalculate_trips_distance.rb index 6c02bd3a..9353ac18 100644 --- a/db/data/20250709195003_recalculate_trips_distance.rb +++ b/db/data/20250709195003_recalculate_trips_distance.rb @@ -2,9 +2,7 @@ class RecalculateTripsDistance < ActiveRecord::Migration[8.0] def up - Trip.find_each do |trip| - trip.enqueue_calculation_jobs - end + Trip.find_each(&:enqueue_calculation_jobs) end def down diff --git a/db/migrate/20220325100310_devise_create_users.rb b/db/migrate/20220325100310_devise_create_users.rb index 43927dbd..e052375d 100644 --- a/db/migrate/20220325100310_devise_create_users.rb +++ b/db/migrate/20220325100310_devise_create_users.rb @@ -4,8 +4,8 @@ class DeviseCreateUsers < ActiveRecord::Migration[7.0] def change create_table :users do |t| ## Database authenticatable - t.string :email, null: false, default: "" - t.string :encrypted_password, null: false, default: "" + t.string :email, null: false, default: '' + t.string :encrypted_password, null: false, default: '' ## Recoverable t.string :reset_password_token @@ -32,7 +32,6 @@ class DeviseCreateUsers < ActiveRecord::Migration[7.0] # t.string :unlock_token # Only if unlock strategy is :email or :both # t.datetime :locked_at - t.timestamps null: false end diff --git a/db/migrate/20250821192219_add_points_count_to_users.rb b/db/migrate/20250821192219_add_points_count_to_users.rb new file mode 100644 index 00000000..86f056a6 --- /dev/null +++ b/db/migrate/20250821192219_add_points_count_to_users.rb @@ -0,0 +1,12 @@ +class AddPointsCountToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :points_count, :integer, default: 0, null: false + + # Initialize counter cache for existing users using background job + reversible do |dir| + dir.up do + DataMigrations::PrefillPointsCounterCacheJob.perform_later + end + end + end +end diff --git a/db/migrate/20250823125940_remove_default_from_imports_source.rb b/db/migrate/20250823125940_remove_default_from_imports_source.rb new file mode 100644 index 00000000..4b99017d --- /dev/null +++ b/db/migrate/20250823125940_remove_default_from_imports_source.rb @@ -0,0 +1,5 @@ +class RemoveDefaultFromImportsSource < ActiveRecord::Migration[8.0] + def change + change_column_default :imports, :source, from: 0, to: nil + end +end diff --git a/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb b/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb new file mode 100644 index 00000000..afe2643a --- /dev/null +++ b/db/migrate/20250905120121_add_user_country_composite_index_to_points.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddUserCountryCompositeIndexToPoints < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :points, %i[user_id country_name], + algorithm: :concurrently, + name: 'idx_points_user_country_name', + if_not_exists: true + end +end diff --git a/db/migrate/20250910224538_add_sharing_fields_to_stats.rb b/db/migrate/20250910224538_add_sharing_fields_to_stats.rb new file mode 100644 index 00000000..0035bc3d --- /dev/null +++ b/db/migrate/20250910224538_add_sharing_fields_to_stats.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddSharingFieldsToStats < ActiveRecord::Migration[8.0] + def up + add_column :stats, :sharing_settings, :jsonb + add_column :stats, :sharing_uuid, :uuid + + change_column_default :stats, :sharing_settings, {} + + BulkStatsCalculatingJob.set(wait: 5.minutes).perform_later + end + + def down + remove_column :stats, :sharing_settings + remove_column :stats, :sharing_uuid + end +end diff --git a/db/migrate/20250910224714_add_index_to_stats_share_uuid.rb b/db/migrate/20250910224714_add_index_to_stats_share_uuid.rb new file mode 100644 index 00000000..6e1d0bc3 --- /dev/null +++ b/db/migrate/20250910224714_add_index_to_stats_share_uuid.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddIndexToStatsShareUuid < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :stats, :sharing_uuid, unique: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb new file mode 100644 index 00000000..cdb627e9 --- /dev/null +++ b/db/migrate/20250918215512_add_h3_hex_ids_to_stats.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddH3HexIdsToStats < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true + safety_assured do + add_index :stats, :h3_hex_ids, using: :gin, + where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)", + algorithm: :concurrently, if_not_exists: true + end + end +end diff --git a/db/migrate/20250926220114_create_families.rb b/db/migrate/20250926220114_create_families.rb new file mode 100644 index 00000000..cbaeaf25 --- /dev/null +++ b/db/migrate/20250926220114_create_families.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateFamilies < ActiveRecord::Migration[8.0] + def change + create_table :families do |t| + t.string :name, null: false, limit: 50 + t.bigint :creator_id, null: false + t.timestamps + end + + add_foreign_key :families, :users, column: :creator_id, validate: false + add_index :families, :creator_id + end +end diff --git a/db/migrate/20250926220135_create_family_memberships.rb b/db/migrate/20250926220135_create_family_memberships.rb new file mode 100644 index 00000000..fa8e051a --- /dev/null +++ b/db/migrate/20250926220135_create_family_memberships.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateFamilyMemberships < ActiveRecord::Migration[8.0] + def change + create_table :family_memberships do |t| + t.bigint :family_id, null: false + t.bigint :user_id, null: false + t.integer :role, null: false, default: 1 # member + t.timestamps + end + + add_foreign_key :family_memberships, :families, validate: false + add_foreign_key :family_memberships, :users, validate: false + add_index :family_memberships, :user_id, unique: true # One family per user + add_index :family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role' + end +end diff --git a/db/migrate/20250926220158_create_family_invitations.rb b/db/migrate/20250926220158_create_family_invitations.rb new file mode 100644 index 00000000..be841652 --- /dev/null +++ b/db/migrate/20250926220158_create_family_invitations.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CreateFamilyInvitations < ActiveRecord::Migration[8.0] + def change + create_table :family_invitations do |t| + t.bigint :family_id, null: false + t.string :email, null: false + t.string :token, null: false + t.datetime :expires_at, null: false + t.bigint :invited_by_id, null: false + t.integer :status, null: false, default: 0 # pending + t.timestamps + end + + add_foreign_key :family_invitations, :families, validate: false + add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false + add_index :family_invitations, :token, unique: true + add_index :family_invitations, %i[family_id email], name: 'index_family_invitations_on_family_id_and_email' + add_index :family_invitations, %i[family_id status expires_at], + name: 'index_family_invitations_on_family_status_expires' + add_index :family_invitations, %i[status expires_at], + name: 'index_family_invitations_on_status_and_expires_at' + add_index :family_invitations, %i[status updated_at], + name: 'index_family_invitations_on_status_and_updated_at' + end +end diff --git a/db/migrate/20250926220345_validate_family_foreign_keys.rb b/db/migrate/20250926220345_validate_family_foreign_keys.rb new file mode 100644 index 00000000..45461b79 --- /dev/null +++ b/db/migrate/20250926220345_validate_family_foreign_keys.rb @@ -0,0 +1,9 @@ +class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :families, :users + validate_foreign_key :family_memberships, :families + validate_foreign_key :family_memberships, :users + validate_foreign_key :family_invitations, :families + validate_foreign_key :family_invitations, :users + end +end diff --git a/db/migrate/20250928000001_add_family_performance_indexes.rb b/db/migrate/20250928000001_add_family_performance_indexes.rb new file mode 100644 index 00000000..3022160d --- /dev/null +++ b/db/migrate/20250928000001_add_family_performance_indexes.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class AddFamilyPerformanceIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + # Index for family invitations queries + unless index_exists?(:family_invitations, %i[family_id status expires_at], + name: 'index_family_invitations_on_family_status_expires') + add_index :family_invitations, %i[family_id status expires_at], + name: 'index_family_invitations_on_family_status_expires', + algorithm: :concurrently + end + + # Index for family membership queries by role + unless index_exists?(:family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role') + add_index :family_memberships, %i[family_id role], + name: 'index_family_memberships_on_family_and_role', + algorithm: :concurrently + end + + # Composite index for active invitations + unless index_exists?(:family_invitations, %i[status expires_at], + name: 'index_family_invitations_on_status_and_expires_at') + add_index :family_invitations, %i[status expires_at], + name: 'index_family_invitations_on_status_and_expires_at', + algorithm: :concurrently + end + + # Cleanup job support for status and updated_at + unless index_exists?(:family_invitations, %i[status updated_at], + name: 'index_family_invitations_on_status_and_updated_at') + add_index :family_invitations, %i[status updated_at], + name: 'index_family_invitations_on_status_and_updated_at', + algorithm: :concurrently + end + end +end diff --git a/db/schema.rb b/db/schema.rb index feac06e4..c0f8d0cd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do +ActiveRecord::Schema[8.0].define(version: 2025_09_30_150256) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "postgis" @@ -96,10 +96,45 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do t.index ["user_id"], name: "index_exports_on_user_id" end + create_table "families", force: :cascade do |t| + t.string "name", limit: 50, null: false + t.bigint "creator_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "index_families_on_creator_id" + end + + create_table "family_invitations", force: :cascade do |t| + t.bigint "family_id", null: false + t.string "email", null: false + t.string "token", null: false + t.datetime "expires_at", null: false + t.bigint "invited_by_id", null: false + t.integer "status", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_family_invitations_on_email" + t.index ["expires_at"], name: "index_family_invitations_on_expires_at" + t.index ["family_id"], name: "index_family_invitations_on_family_id" + t.index ["status"], name: "index_family_invitations_on_status" + t.index ["token"], name: "index_family_invitations_on_token", unique: true + end + + create_table "family_memberships", force: :cascade do |t| + t.bigint "family_id", null: false + t.bigint "user_id", null: false + t.integer "role", default: 1, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "role"], name: "index_family_memberships_on_family_id_and_role" + t.index ["family_id"], name: "index_family_memberships_on_family_id" + t.index ["user_id"], name: "index_family_memberships_on_user_id", unique: true + end + create_table "imports", force: :cascade do |t| t.string "name", null: false t.bigint "user_id", null: false - t.integer "source", default: 0 + t.integer "source" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "raw_points", default: 0 @@ -205,6 +240,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do t.index ["timestamp"], name: "index_points_on_timestamp" t.index ["track_id"], name: "index_points_on_track_id" t.index ["trigger"], name: "index_points_on_trigger" + t.index ["user_id", "country_name"], name: "idx_points_user_country_name" t.index ["user_id", "timestamp", "track_id"], name: "idx_points_track_generation" t.index ["user_id"], name: "index_points_on_user_id" t.index ["visit_id"], name: "index_points_on_visit_id" @@ -219,8 +255,13 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do t.datetime "updated_at", null: false t.bigint "user_id", null: false t.jsonb "daily_distance", default: {} + t.jsonb "sharing_settings", default: {} + t.uuid "sharing_uuid" + t.jsonb "h3_hex_ids", default: {} t.index ["distance"], name: "index_stats_on_distance" + t.index ["h3_hex_ids"], name: "index_stats_on_h3_hex_ids", where: "((h3_hex_ids IS NOT NULL) AND (h3_hex_ids <> '{}'::jsonb))", using: :gin t.index ["month"], name: "index_stats_on_month" + t.index ["sharing_uuid"], name: "index_stats_on_sharing_uuid", unique: true t.index ["user_id"], name: "index_stats_on_user_id" t.index ["year"], name: "index_stats_on_year" end @@ -230,7 +271,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do t.datetime "end_at", null: false t.bigint "user_id", null: false t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false - t.integer "distance" + t.decimal "distance", precision: 8, scale: 2 t.float "avg_speed" t.integer "duration" t.integer "elevation_gain" @@ -274,6 +315,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do t.string "last_sign_in_ip" t.integer "status", default: 0 t.datetime "active_until" + t.integer "points_count", default: 0, null: false t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end @@ -300,6 +342,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "areas", "users" + add_foreign_key "families", "users", column: "creator_id", validate: false + add_foreign_key "family_invitations", "families", validate: false + add_foreign_key "family_invitations", "users", column: "invited_by_id", validate: false + add_foreign_key "family_memberships", "families", validate: false + add_foreign_key "family_memberships", "users", validate: false add_foreign_key "notifications", "users" add_foreign_key "place_visits", "places" add_foreign_key "place_visits", "visits" diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index aed33719..77553008 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM ruby:3.4.1-slim +FROM ruby:3.4.6-slim ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 @@ -12,7 +12,10 @@ ENV SIDEKIQ_PASSWORD=password # Resolving sqlite3 error ENV PGSSENCMODE=disable -RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + curl \ wget \ build-essential \ git \ @@ -24,13 +27,27 @@ RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no libgeos-dev libgeos++-dev \ imagemagick \ tzdata \ - nodejs \ - yarn \ less \ libjemalloc2 libjemalloc-dev \ + cmake \ + ca-certificates \ && mkdir -p $APP_PATH \ && rm -rf /var/lib/apt/lists/* +# Install Node.js using official NodeSource script +# NodeSource supports: amd64, arm64, armhf (arm/v7) +# For unsupported architectures, fall back to Debian's nodejs package +RUN ARCH=$(dpkg --print-architecture) && \ + if [ "$ARCH" = "amd64" ] || [ "$ARCH" = "arm64" ] || [ "$ARCH" = "armhf" ]; then \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs; \ + else \ + apt-get update && \ + apt-get install -y nodejs npm; \ + fi && \ + npm install -g yarn && \ + rm -rf /var/lib/apt/lists/* + # Use jemalloc with check for architecture RUN if [ "$(uname -m)" = "x86_64" ]; then \ echo "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \ @@ -42,7 +59,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \ ENV RUBY_YJIT_ENABLE=1 # Update RubyGems and install Bundler -RUN gem update --system 3.6.2 \ +RUN gem update --system 3.6.9 \ && gem install bundler --version "$BUNDLE_VERSION" \ && rm -rf $GEM_HOME/cache/* @@ -52,7 +69,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ RUN bundle config set --local path 'vendor/bundle' \ && bundle install --jobs 4 --retry 3 \ - && rm -rf vendor/bundle/ruby/3.4.1/cache/*.gem + && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem COPY ../. ./ diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index e5fd1d61..29279c81 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -1,4 +1,4 @@ -FROM ruby:3.4.1-slim +FROM ruby:3.4.6-slim ENV APP_PATH=/var/app ENV BUNDLE_VERSION=2.5.21 @@ -7,7 +7,10 @@ ENV RAILS_LOG_TO_STDOUT=true ENV RAILS_PORT=3000 ENV RAILS_ENV=production -RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ +RUN apt-get update -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + curl \ wget \ build-essential \ git \ @@ -19,10 +22,12 @@ RUN apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y --no libgeos-dev libgeos++-dev \ imagemagick \ tzdata \ - nodejs \ - yarn \ less \ libjemalloc2 libjemalloc-dev \ + cmake \ + && curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g yarn \ && mkdir -p $APP_PATH \ && rm -rf /var/lib/apt/lists/* @@ -37,7 +42,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \ ENV RUBY_YJIT_ENABLE=1 # Update gem system and install bundler -RUN gem update --system 3.6.2 \ +RUN gem update --system 3.6.9 \ && gem install bundler --version "$BUNDLE_VERSION" \ && rm -rf $GEM_HOME/cache/* @@ -49,7 +54,7 @@ COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./ RUN bundle config set --local path 'vendor/bundle' \ && bundle config set --local without 'development test' \ && bundle install --jobs 4 --retry 3 \ - && rm -rf vendor/bundle/ruby/3.4.1/cache/*.gem + && rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem COPY ../. ./ diff --git a/lib/json_stream_handler.rb b/lib/json_stream_handler.rb new file mode 100644 index 00000000..f9e6b372 --- /dev/null +++ b/lib/json_stream_handler.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +# Streaming JSON handler relays sections and streamed values back to the importer instance. + +class JsonStreamHandler < Oj::Saj + HashState = Struct.new(:hash, :root, :key) + ArrayState = Struct.new(:array, :key) + StreamState = Struct.new(:key) + + def initialize(processor) + super() + @processor = processor + @stack = [] + end + + def hash_start(key = nil, *_) + state = HashState.new({}, @stack.empty?, normalize_key(key)) + @stack << state + end + + def hash_end(key = nil, *_) + state = @stack.pop + value = state.hash + parent = @stack.last + + dispatch_to_parent(parent, value, normalize_key(key) || state.key) + end + + def array_start(key = nil, *_) + normalized_key = normalize_key(key) + parent = @stack.last + + if parent.is_a?(HashState) && parent.root && @stack.size == 1 && Users::ImportData::STREAMED_SECTIONS.include?(normalized_key) + @stack << StreamState.new(normalized_key) + else + @stack << ArrayState.new([], normalized_key) + end + end + + def array_end(key = nil, *_) + state = @stack.pop + case state + when StreamState + @processor.send(:finish_stream, state.key) + when ArrayState + value = state.array + parent = @stack.last + dispatch_to_parent(parent, value, normalize_key(key) || state.key) + end + end + + def add_value(value, key) + parent = @stack.last + dispatch_to_parent(parent, value, normalize_key(key)) + end + + private + + def normalize_key(key) + key&.to_s + end + + def dispatch_to_parent(parent, value, key) + return unless parent + + case parent + when HashState + if parent.root && @stack.size == 1 + @processor.send(:handle_section, key, value) + else + parent.hash[key] = value + end + when ArrayState + parent.array << value + when StreamState + @processor.send(:handle_stream_value, parent.key, value) + end + end +end diff --git a/lib/tasks/webmanifest.rake b/lib/tasks/webmanifest.rake new file mode 100644 index 00000000..22264d3a --- /dev/null +++ b/lib/tasks/webmanifest.rake @@ -0,0 +1,43 @@ +namespace :webmanifest do + desc "Generate site.webmanifest in public directory with correct asset paths" + task :generate => :environment do + require 'erb' + + # Make sure assets are compiled first by loading the manifest + Rails.application.assets_manifest.assets + + # Get the correct asset paths + icon_192_path = ActionController::Base.helpers.asset_path('favicon/android-chrome-192x192.png') + icon_512_path = ActionController::Base.helpers.asset_path('favicon/android-chrome-512x512.png') + + # Generate the manifest content + manifest_content = { + "name": "Dawarich", + "short_name": "Dawarich", + "icons": [ + { + "src": icon_192_path, + "sizes": "192x192", + "type": "image/png" + }, + { + "src": icon_512_path, + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" + }.to_json + + # Write to public/site.webmanifest + File.write(Rails.root.join('public/site.webmanifest'), manifest_content) + puts "Generated public/site.webmanifest with correct asset paths" + end +end + +# Hook to automatically generate webmanifest after assets:precompile +# Rake::Task['assets:precompile'].enhance do +# Rake::Task['webmanifest:generate'].invoke +# end diff --git a/public/.well-known/apple-app-site-association b/public/.well-known/apple-app-site-association index fbf3900a..b32ab0f3 100644 --- a/public/.well-known/apple-app-site-association +++ b/public/.well-known/apple-app-site-association @@ -1,7 +1,8 @@ { "webcredentials": { "apps": [ - "2A275P77DQ.app.dawarich.Dawarich" + "2A275P77DQ.app.dawarich.Dawarich", + "3DJN84WAS8.app.dawarich.Dawarich" ] } } diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 00000000..7a172c87 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"Dawarich","short_name":"Dawarich","icons":[{"src":"/assets/favicon/android-chrome-192x192-f9610e2af28e4e48ff0472572c0cb9e3902d29bccc2b07f8f03aabf684822355.png","sizes":"192x192","type":"image/png"},{"src":"/assets/favicon/android-chrome-512x512-c2ec8132d773ae99f53955360cdd5691bb38e0ed141bddebd39d896b78b5afb6.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/spec/factories/families.rb b/spec/factories/families.rb new file mode 100644 index 00000000..9958a049 --- /dev/null +++ b/spec/factories/families.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :family do + sequence(:name) { |n| "Test Family #{n}" } + association :creator, factory: :user + end +end diff --git a/spec/factories/family_invitations.rb b/spec/factories/family_invitations.rb new file mode 100644 index 00000000..41e71969 --- /dev/null +++ b/spec/factories/family_invitations.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :family_invitation, class: 'Family::Invitation' do + association :family + association :invited_by, factory: :user + sequence(:email) { |n| "invite#{n}@example.com" } + token { SecureRandom.urlsafe_base64(32) } + expires_at { 7.days.from_now } + status { :pending } + + trait :accepted do + status { :accepted } + end + + trait :expired do + status { :expired } + expires_at { 1.day.ago } + end + + trait :cancelled do + status { :cancelled } + end + + trait :with_expired_date do + expires_at { 1.day.ago } + end + end +end diff --git a/spec/factories/family_memberships.rb b/spec/factories/family_memberships.rb new file mode 100644 index 00000000..0796c9af --- /dev/null +++ b/spec/factories/family_memberships.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :family_membership, class: 'Family::Membership' do + association :family + association :user + role { :member } + + trait :owner do + role { :owner } + end + end +end diff --git a/spec/factories/imports.rb b/spec/factories/imports.rb index 51670b6b..9ae0acea 100644 --- a/spec/factories/imports.rb +++ b/spec/factories/imports.rb @@ -4,7 +4,7 @@ FactoryBot.define do factory :import do user sequence(:name) { |n| "owntracks_export_#{n}.json" } - source { Import.sources[:owntracks] } + # source { Import.sources[:owntracks] } trait :with_points do after(:create) do |import| diff --git a/spec/factories/points.rb b/spec/factories/points.rb index acc097e9..779f18f0 100644 --- a/spec/factories/points.rb +++ b/spec/factories/points.rb @@ -28,7 +28,7 @@ FactoryBot.define do course { nil } course_accuracy { nil } external_track_id { nil } - lonlat { "POINT(#{FFaker::Geolocation.lng} #{FFaker::Geolocation.lat})" } + lonlat { "POINT(#{longitude} #{latitude})" } user country_id { nil } diff --git a/spec/factories/stats.rb b/spec/factories/stats.rb index 4a2ade2a..16be6795 100644 --- a/spec/factories/stats.rb +++ b/spec/factories/stats.rb @@ -6,6 +6,8 @@ FactoryBot.define do month { 1 } distance { 1000 } # 1 km user + sharing_settings { {} } + sharing_uuid { SecureRandom.uuid } toponyms do [ { @@ -16,5 +18,31 @@ FactoryBot.define do }, { 'cities' => [], 'country' => nil } ] end + + trait :with_sharing_enabled do + after(:create) do |stat, _evaluator| + stat.enable_sharing!(expiration: '24h') + end + end + + trait :with_sharing_disabled do + sharing_settings do + { + 'enabled' => false, + 'expiration' => nil, + 'expires_at' => nil + } + end + end + + trait :with_sharing_expired do + sharing_settings do + { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + } + end + end end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 3e27ad70..8aead742 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :user do sequence :email do |n| - "user#{n}@example.com" + "user#{n}-#{Time.current.to_f}@example.com" end status { :active } diff --git a/spec/fixtures/files/google/phone-takeout.json b/spec/fixtures/files/google/phone-takeout_w_3_duplicates.json similarity index 99% rename from spec/fixtures/files/google/phone-takeout.json rename to spec/fixtures/files/google/phone-takeout_w_3_duplicates.json index f8442949..59b45587 100644 --- a/spec/fixtures/files/google/phone-takeout.json +++ b/spec/fixtures/files/google/phone-takeout_w_3_duplicates.json @@ -1,5 +1,3 @@ -// This file contains 3 doubles - { "semanticSegments": [ { diff --git a/spec/jobs/area_visits_calculation_scheduling_job_spec.rb b/spec/jobs/area_visits_calculation_scheduling_job_spec.rb index b38ee551..39fae4d7 100644 --- a/spec/jobs/area_visits_calculation_scheduling_job_spec.rb +++ b/spec/jobs/area_visits_calculation_scheduling_job_spec.rb @@ -8,6 +8,7 @@ RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do let(:area) { create(:area, user: user) } it 'calls the AreaVisitsCalculationService' do + allow(User).to receive(:find_each).and_yield(user) expect(AreaVisitsCalculatingJob).to receive(:perform_later).with(user.id).and_call_original described_class.new.perform diff --git a/spec/jobs/bulk_stats_calculating_job_spec.rb b/spec/jobs/bulk_stats_calculating_job_spec.rb index 632fa47e..bdcc17f9 100644 --- a/spec/jobs/bulk_stats_calculating_job_spec.rb +++ b/spec/jobs/bulk_stats_calculating_job_spec.rb @@ -4,28 +4,149 @@ require 'rails_helper' RSpec.describe BulkStatsCalculatingJob, type: :job do describe '#perform' do - let(:user1) { create(:user) } - let(:user2) { create(:user) } - let(:timestamp) { DateTime.new(2024, 1, 1).to_i } - let!(:points1) do - (1..10).map do |i| - create(:point, user_id: user1.id, timestamp: timestamp + i.minutes) + context 'with active users' do + let!(:active_user1) { create(:user, status: :active) } + let!(:active_user2) { create(:user, status: :active) } + + let!(:points1) do + (1..10).map do |i| + create(:point, user_id: active_user1.id, timestamp: timestamp + i.minutes) + end + end + + let!(:points2) do + (1..10).map do |i| + create(:point, user_id: active_user2.id, timestamp: timestamp + i.minutes) + end + end + + before do + allow(Stats::BulkCalculator).to receive(:new).and_call_original + allow_any_instance_of(Stats::BulkCalculator).to receive(:call) + end + + it 'processes all active users' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).with(active_user1.id) + expect(Stats::BulkCalculator).to have_received(:new).with(active_user2.id) + end + + it 'calls Stats::BulkCalculator for each active user' do + calculator1 = instance_double(Stats::BulkCalculator) + calculator2 = instance_double(Stats::BulkCalculator) + + allow(Stats::BulkCalculator).to receive(:new).with(active_user1.id).and_return(calculator1) + allow(Stats::BulkCalculator).to receive(:new).with(active_user2.id).and_return(calculator2) + allow(calculator1).to receive(:call) + allow(calculator2).to receive(:call) + + BulkStatsCalculatingJob.perform_now + + expect(calculator1).to have_received(:call) + expect(calculator2).to have_received(:call) end end - let!(:points2) do - (1..10).map do |i| - create(:point, user_id: user2.id, timestamp: timestamp + i.minutes) + context 'with trial users' do + let!(:trial_user1) { create(:user, status: :trial) } + let!(:trial_user2) { create(:user, status: :trial) } + + let!(:points1) do + (1..5).map do |i| + create(:point, user_id: trial_user1.id, timestamp: timestamp + i.minutes) + end + end + + let!(:points2) do + (1..5).map do |i| + create(:point, user_id: trial_user2.id, timestamp: timestamp + i.minutes) + end + end + + before do + allow(Stats::BulkCalculator).to receive(:new).and_call_original + allow_any_instance_of(Stats::BulkCalculator).to receive(:call) + end + + it 'processes all trial users' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).with(trial_user1.id) + expect(Stats::BulkCalculator).to have_received(:new).with(trial_user2.id) + end + + it 'calls Stats::BulkCalculator for each trial user' do + calculator1 = instance_double(Stats::BulkCalculator) + calculator2 = instance_double(Stats::BulkCalculator) + + allow(Stats::BulkCalculator).to receive(:new).with(trial_user1.id).and_return(calculator1) + allow(Stats::BulkCalculator).to receive(:new).with(trial_user2.id).and_return(calculator2) + allow(calculator1).to receive(:call) + allow(calculator2).to receive(:call) + + BulkStatsCalculatingJob.perform_now + + expect(calculator1).to have_received(:call) + expect(calculator2).to have_received(:call) end end - it 'enqueues Stats::CalculatingJob for each user' do - expect(Stats::CalculatingJob).to receive(:perform_later).with(user1.id, 2024, 1) - expect(Stats::CalculatingJob).to receive(:perform_later).with(user2.id, 2024, 1) + context 'with inactive users only' do + before do + allow(User).to receive(:active).and_return(User.none) + allow(User).to receive(:trial).and_return(User.none) + allow(Stats::BulkCalculator).to receive(:new) + end - BulkStatsCalculatingJob.perform_now + it 'does not process any users when no active or trial users exist' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).not_to have_received(:new) + end + + it 'queries for active and trial users but finds none' do + BulkStatsCalculatingJob.perform_now + + expect(User).to have_received(:active) + expect(User).to have_received(:trial) + end + end + + context 'with mixed user types' do + let(:active_user) { create(:user, status: :active) } + let(:trial_user) { create(:user, status: :trial) } + let(:inactive_user) { create(:user, status: :inactive) } + + before do + active_users_relation = double('ActiveRecord::Relation') + trial_users_relation = double('ActiveRecord::Relation') + + allow(active_users_relation).to receive(:pluck).with(:id).and_return([active_user.id]) + allow(trial_users_relation).to receive(:pluck).with(:id).and_return([trial_user.id]) + + allow(User).to receive(:active).and_return(active_users_relation) + allow(User).to receive(:trial).and_return(trial_users_relation) + + allow(Stats::BulkCalculator).to receive(:new).and_call_original + allow_any_instance_of(Stats::BulkCalculator).to receive(:call) + end + + it 'processes only active and trial users, skipping inactive users' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).with(active_user.id) + expect(Stats::BulkCalculator).to have_received(:new).with(trial_user.id) + expect(Stats::BulkCalculator).not_to have_received(:new).with(inactive_user.id) + end + + it 'processes exactly 2 users (active and trial)' do + BulkStatsCalculatingJob.perform_now + + expect(Stats::BulkCalculator).to have_received(:new).exactly(2).times + end end end end diff --git a/spec/jobs/bulk_visits_suggesting_job_spec.rb b/spec/jobs/bulk_visits_suggesting_job_spec.rb index 66bf7da6..b63dfa81 100644 --- a/spec/jobs/bulk_visits_suggesting_job_spec.rb +++ b/spec/jobs/bulk_visits_suggesting_job_spec.rb @@ -54,6 +54,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do ] allow_any_instance_of(Visits::TimeChunks).to receive(:call).and_return(chunks) + active_users_mock = double('ActiveRecord::Relation') + allow(User).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock) + allow(active_users_mock).to receive(:find_each).and_yield(user_with_points) + chunks.each do |chunk| expect(VisitSuggestingJob).to receive(:perform_later).with( user_id: user_with_points.id, @@ -94,6 +100,12 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do .and_return(time_chunks_instance) allow(time_chunks_instance).to receive(:call).and_return(custom_chunks) + active_users_mock = double('ActiveRecord::Relation') + allow(User).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:active).and_return(active_users_mock) + allow(active_users_mock).to receive(:where).with(id: []).and_return(active_users_mock) + allow(active_users_mock).to receive(:find_each).and_yield(user_with_points) + expect(VisitSuggestingJob).to receive(:perform_later).with( user_id: user_with_points.id, start_at: custom_chunks.first.first, diff --git a/spec/jobs/cache/preheating_job_spec.rb b/spec/jobs/cache/preheating_job_spec.rb new file mode 100644 index 00000000..fc809194 --- /dev/null +++ b/spec/jobs/cache/preheating_job_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Cache::PreheatingJob do + before { Rails.cache.clear } + + describe '#perform' do + let!(:user1) { create(:user) } + let!(:user2) { create(:user) } + let!(:import1) { create(:import, user: user1) } + let!(:import2) { create(:import, user: user2) } + let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" } + let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" } + let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" } + let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" } + let(:user_1_countries_visited_key) { "dawarich/user_#{user1.id}_countries_visited" } + let(:user_2_countries_visited_key) { "dawarich/user_#{user2.id}_countries_visited" } + let(:user_1_cities_visited_key) { "dawarich/user_#{user1.id}_cities_visited" } + let(:user_2_cities_visited_key) { "dawarich/user_#{user2.id}_cities_visited" } + + before do + create_list(:point, 3, user: user1, import: import1, reverse_geocoded_at: Time.current) + create_list(:point, 2, user: user2, import: import2, reverse_geocoded_at: Time.current) + end + + it 'preheats years_tracked cache for all users' do + # Clear cache before test to ensure clean state + Rails.cache.clear + + described_class.new.perform + + # Verify that cache keys exist after job runs + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true + + # Verify the cached data is reasonable + user1_years = Rails.cache.read(user_1_years_tracked_key) + user2_years = Rails.cache.read(user_2_years_tracked_key) + + expect(user1_years).to be_an(Array) + expect(user2_years).to be_an(Array) + end + + it 'preheats points_geocoded_stats cache for all users' do + # Clear cache before test to ensure clean state + Rails.cache.clear + + described_class.new.perform + + # Verify that cache keys exist after job runs + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true + + # Verify the cached data has the expected structure + user1_stats = Rails.cache.read(user_1_points_geocoded_stats_key) + user2_stats = Rails.cache.read(user_2_points_geocoded_stats_key) + + expect(user1_stats).to be_a(Hash) + expect(user1_stats).to have_key(:geocoded) + expect(user1_stats).to have_key(:without_data) + expect(user1_stats[:geocoded]).to eq(3) + + expect(user2_stats).to be_a(Hash) + expect(user2_stats).to have_key(:geocoded) + expect(user2_stats).to have_key(:without_data) + expect(user2_stats[:geocoded]).to eq(2) + end + + it 'actually writes to cache' do + described_class.new.perform + + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_1_countries_visited_key)).to be true + expect(Rails.cache.exist?(user_1_cities_visited_key)).to be true + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_2_countries_visited_key)).to be true + expect(Rails.cache.exist?(user_2_cities_visited_key)).to be true + end + + it 'handles users with no points gracefully' do + user_no_points = create(:user) + + expect { described_class.new.perform }.not_to raise_error + + cached_stats = Rails.cache.read("dawarich/user_#{user_no_points.id}_points_geocoded_stats") + expect(cached_stats).to eq({ geocoded: 0, without_data: 0 }) + end + end +end diff --git a/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb b/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb index 2bb19f2a..5fb3e701 100644 --- a/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb +++ b/spec/jobs/data_migrations/migrate_points_latlon_job_spec.rb @@ -7,10 +7,13 @@ RSpec.describe DataMigrations::MigratePointsLatlonJob, type: :job do it 'updates the lonlat column for all tracked points' do user = create(:user) point = create(:point, latitude: 2.0, longitude: 1.0, user: user) + + # Clear the lonlat to simulate points that need migration + point.update_column(:lonlat, nil) expect { subject.perform(user.id) }.to change { point.reload.lonlat - }.to(RGeo::Geographic.spherical_factory.point(1.0, 2.0)) + }.from(nil).to(RGeo::Geographic.spherical_factory.point(1.0, 2.0)) end end end diff --git a/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb b/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb new file mode 100644 index 00000000..28dbb9a5 --- /dev/null +++ b/spec/jobs/points/nightly_reverse_geocoding_job_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Points::NightlyReverseGeocodingJob, type: :job do + describe '#perform' do + let(:user) { create(:user) } + + before do + # Clear any existing jobs and points to ensure test isolation + ActiveJob::Base.queue_adapter.enqueued_jobs.clear + Point.delete_all + end + + context 'when reverse geocoding is disabled' do + before do + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false) + end + + let!(:point_without_geocoding) do + create(:point, user: user, reverse_geocoded_at: nil) + end + + it 'does not process any points' do + expect_any_instance_of(Point).not_to receive(:async_reverse_geocode) + + described_class.perform_now + end + + it 'returns early without querying points' do + allow(Point).to receive(:not_reverse_geocoded) + + described_class.perform_now + + expect(Point).not_to have_received(:not_reverse_geocoded) + end + + it 'does not enqueue any ReverseGeocodingJob jobs' do + expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob) + end + end + + context 'when reverse geocoding is enabled' do + before do + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) + end + + context 'with no points needing reverse geocoding' do + let!(:geocoded_point) do + create(:point, user: user, reverse_geocoded_at: 1.day.ago) + end + + it 'does not process any points' do + expect_any_instance_of(Point).not_to receive(:async_reverse_geocode) + + described_class.perform_now + end + + it 'does not enqueue any ReverseGeocodingJob jobs' do + expect { described_class.perform_now }.not_to have_enqueued_job(ReverseGeocodingJob) + end + end + + context 'with points needing reverse geocoding' do + let!(:point_without_geocoding1) do + create(:point, user: user, reverse_geocoded_at: nil) + end + let!(:point_without_geocoding2) do + create(:point, user: user, reverse_geocoded_at: nil) + end + let!(:geocoded_point) do + create(:point, user: user, reverse_geocoded_at: 1.day.ago) + end + + it 'processes all points that need reverse geocoding' do + expect { described_class.perform_now }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times + end + + it 'enqueues jobs with correct parameters' do + expect { described_class.perform_now } + .to have_enqueued_job(ReverseGeocodingJob) + .with('Point', point_without_geocoding1.id) + .and have_enqueued_job(ReverseGeocodingJob) + .with('Point', point_without_geocoding2.id) + end + + it 'uses find_each with correct batch size' do + relation_mock = double('ActiveRecord::Relation') + allow(Point).to receive(:not_reverse_geocoded).and_return(relation_mock) + allow(relation_mock).to receive(:find_each).with(batch_size: 1000) + + described_class.perform_now + + expect(relation_mock).to have_received(:find_each).with(batch_size: 1000) + end + end + end + + describe 'queue configuration' do + it 'uses the reverse_geocoding queue' do + expect(described_class.queue_name).to eq('reverse_geocoding') + end + end + + describe 'error handling' do + before do + allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) + end + + let!(:point_without_geocoding) do + create(:point, user: user, reverse_geocoded_at: nil) + end + + context 'when a point fails to reverse geocode' do + before do + allow_any_instance_of(Point).to receive(:async_reverse_geocode).and_raise(StandardError, 'API error') + end + + it 'continues processing other points despite individual failures' do + expect { described_class.perform_now }.to raise_error(StandardError, 'API error') + end + end + end + end +end diff --git a/spec/jobs/tracks/cleanup_job_spec.rb b/spec/jobs/tracks/cleanup_job_spec.rb deleted file mode 100644 index d4823f86..00000000 --- a/spec/jobs/tracks/cleanup_job_spec.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Tracks::CleanupJob, type: :job do - let(:user) { create(:user) } - - describe '#perform' do - context 'with old untracked points' do - let!(:old_points) do - create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i) - create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 1.day.ago.to_i) - end - let!(:recent_points) do - create_points_around(user: user, count: 2, base_lat: 20.0, timestamp: 1.hour.ago.to_i) - end - let(:generator) { instance_double(Tracks::Generator) } - - it 'processes only old untracked points' do - expect(Tracks::Generator).to receive(:new) - .and_return(generator) - - expect(generator).to receive(:call) - - described_class.new.perform(older_than: 1.day.ago) - end - - it 'logs processing information' do - allow(Tracks::Generator).to receive(:new).and_return(double(call: nil)) - - expect(Rails.logger).to receive(:info).with(/Processing missed tracks for user #{user.id}/) - - described_class.new.perform(older_than: 1.day.ago) - end - end - - context 'with users having insufficient points' do - let!(:single_point) do - create_points_around(user: user, count: 1, base_lat: 20.0, timestamp: 2.days.ago.to_i) - end - - it 'skips users with less than 2 points' do - expect(Tracks::Generator).not_to receive(:new) - - described_class.new.perform(older_than: 1.day.ago) - end - end - - context 'with no old untracked points' do - let(:track) { create(:track, user: user) } - let!(:tracked_points) do - create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 2.days.ago.to_i, track: track) - end - - it 'does not process any users' do - expect(Tracks::Generator).not_to receive(:new) - - described_class.new.perform(older_than: 1.day.ago) - end - end - - context 'with custom older_than parameter' do - let!(:points) do - create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 3.days.ago.to_i) - end - let(:generator) { instance_double(Tracks::Generator) } - - it 'uses custom threshold' do - expect(Tracks::Generator).to receive(:new) - .and_return(generator) - - expect(generator).to receive(:call) - - described_class.new.perform(older_than: 2.days.ago) - end - end - end - - describe 'job configuration' do - it 'uses tracks queue' do - expect(described_class.queue_name).to eq('tracks') - end - - it 'does not retry on failure' do - expect(described_class.sidekiq_options_hash['retry']).to be false - end - end -end diff --git a/spec/jobs/tracks/create_job_spec.rb b/spec/jobs/tracks/create_job_spec.rb deleted file mode 100644 index bddf430a..00000000 --- a/spec/jobs/tracks/create_job_spec.rb +++ /dev/null @@ -1,200 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Tracks::CreateJob, type: :job do - let(:user) { create(:user) } - - describe '#perform' do - let(:generator_instance) { instance_double(Tracks::Generator) } - let(:notification_service) { instance_double(Notifications::Create) } - - before do - allow(Tracks::Generator).to receive(:new).and_return(generator_instance) - allow(generator_instance).to receive(:call) - allow(Notifications::Create).to receive(:new).and_return(notification_service) - allow(notification_service).to receive(:call) - allow(generator_instance).to receive(:call).and_return(2) - end - - it 'calls the generator and creates a notification' do - described_class.new.perform(user.id) - - expect(Tracks::Generator).to have_received(:new).with( - user, - start_at: nil, - end_at: nil, - mode: :daily - ) - expect(generator_instance).to have_received(:call) - expect(Notifications::Create).to have_received(:new).with( - user: user, - kind: :info, - title: 'Tracks Generated', - content: 'Created 2 tracks from your location data. Check your tracks section to view them.' - ) - expect(notification_service).to have_received(:call) - end - - context 'with custom parameters' do - let(:start_at) { 1.day.ago.beginning_of_day.to_i } - let(:end_at) { 1.day.ago.end_of_day.to_i } - let(:mode) { :daily } - - before do - allow(Tracks::Generator).to receive(:new).and_return(generator_instance) - allow(generator_instance).to receive(:call) - allow(Notifications::Create).to receive(:new).and_return(notification_service) - allow(notification_service).to receive(:call) - allow(generator_instance).to receive(:call).and_return(1) - end - - it 'passes custom parameters to the generator' do - described_class.new.perform(user.id, start_at: start_at, end_at: end_at, mode: mode) - - expect(Tracks::Generator).to have_received(:new).with( - user, - start_at: start_at, - end_at: end_at, - mode: :daily - ) - expect(generator_instance).to have_received(:call) - expect(Notifications::Create).to have_received(:new).with( - user: user, - kind: :info, - title: 'Tracks Generated', - content: 'Created 1 tracks from your location data. Check your tracks section to view them.' - ) - expect(notification_service).to have_received(:call) - end - end - - context 'when generator raises an error' do - let(:error_message) { 'Something went wrong' } - let(:notification_service) { instance_double(Notifications::Create) } - - before do - allow(Tracks::Generator).to receive(:new).and_return(generator_instance) - allow(generator_instance).to receive(:call).and_raise(StandardError, error_message) - allow(Notifications::Create).to receive(:new).and_return(notification_service) - allow(notification_service).to receive(:call) - end - - it 'creates an error notification' do - described_class.new.perform(user.id) - - expect(Notifications::Create).to have_received(:new).with( - user: user, - kind: :error, - title: 'Track Generation Failed', - content: "Failed to generate tracks from your location data: #{error_message}" - ) - expect(notification_service).to have_received(:call) - end - - it 'reports the error using ExceptionReporter' do - allow(ExceptionReporter).to receive(:call) - - described_class.new.perform(user.id) - - expect(ExceptionReporter).to have_received(:call).with( - kind_of(StandardError), - 'Failed to create tracks for user' - ) - end - end - - context 'when user does not exist' do - before do - allow(User).to receive(:find).with(999).and_raise(ActiveRecord::RecordNotFound) - allow(ExceptionReporter).to receive(:call) - allow(Notifications::Create).to receive(:new).and_return(instance_double(Notifications::Create, call: nil)) - end - - it 'handles the error gracefully and creates error notification' do - expect { described_class.new.perform(999) }.not_to raise_error - - expect(ExceptionReporter).to have_received(:call) - end - end - - context 'when tracks are deleted and recreated' do - let(:existing_tracks) { create_list(:track, 3, user: user) } - - before do - allow(generator_instance).to receive(:call).and_return(2) - end - - it 'returns the correct count of newly created tracks' do - described_class.new.perform(user.id, mode: :incremental) - - expect(Tracks::Generator).to have_received(:new).with( - user, - start_at: nil, - end_at: nil, - mode: :incremental - ) - expect(generator_instance).to have_received(:call) - expect(Notifications::Create).to have_received(:new).with( - user: user, - kind: :info, - title: 'Tracks Generated', - content: 'Created 2 tracks from your location data. Check your tracks section to view them.' - ) - expect(notification_service).to have_received(:call) - end - end - end - - describe 'queue' do - it 'is queued on tracks queue' do - expect(described_class.new.queue_name).to eq('tracks') - end - end - - context 'when self-hosted' do - let(:generator_instance) { instance_double(Tracks::Generator) } - let(:notification_service) { instance_double(Notifications::Create) } - let(:error_message) { 'Something went wrong' } - - before do - allow(DawarichSettings).to receive(:self_hosted?).and_return(true) - allow(Tracks::Generator).to receive(:new).and_return(generator_instance) - allow(generator_instance).to receive(:call).and_raise(StandardError, error_message) - allow(Notifications::Create).to receive(:new).and_return(notification_service) - allow(notification_service).to receive(:call) - end - - it 'creates a failure notification when self-hosted' do - described_class.new.perform(user.id) - - expect(Notifications::Create).to have_received(:new).with( - user: user, - kind: :error, - title: 'Track Generation Failed', - content: "Failed to generate tracks from your location data: #{error_message}" - ) - expect(notification_service).to have_received(:call) - end - end - - context 'when not self-hosted' do - let(:generator_instance) { instance_double(Tracks::Generator) } - let(:notification_service) { instance_double(Notifications::Create) } - let(:error_message) { 'Something went wrong' } - - before do - allow(DawarichSettings).to receive(:self_hosted?).and_return(false) - allow(Tracks::Generator).to receive(:new).and_return(generator_instance) - allow(generator_instance).to receive(:call).and_raise(StandardError, error_message) - allow(Notifications::Create).to receive(:new).and_return(notification_service) - allow(notification_service).to receive(:call) - end - - it 'does not create a failure notification' do - described_class.new.perform(user.id) - - expect(notification_service).not_to have_received(:call) - end - end -end diff --git a/spec/jobs/tracks/daily_generation_job_spec.rb b/spec/jobs/tracks/daily_generation_job_spec.rb new file mode 100644 index 00000000..0e5227e5 --- /dev/null +++ b/spec/jobs/tracks/daily_generation_job_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::DailyGenerationJob, type: :job do + describe '#perform' do + let!(:active_user) { create(:user, settings: { 'minutes_between_routes' => 60, 'meters_between_routes' => 500 }) } + let!(:trial_user) { create(:user, :trial) } + let!(:inactive_user) { create(:user, :inactive) } + + let!(:active_user_old_track) do + create(:track, user: active_user, start_at: 2.days.ago, end_at: 2.days.ago + 1.hour) + end + let!(:active_user_new_points) do + create_list(:point, 3, user: active_user, timestamp: 1.hour.ago.to_i) + end + + let!(:trial_user_old_track) do + create(:track, user: trial_user, start_at: 3.days.ago, end_at: 3.days.ago + 1.hour) + end + let!(:trial_user_new_points) do + create_list(:point, 2, user: trial_user, timestamp: 30.minutes.ago.to_i) + end + + before do + active_user.update!(points_count: active_user.points.count) + trial_user.update!(points_count: trial_user.points.count) + + allow(User).to receive(:active_or_trial) + .and_return(User.where(id: [active_user.id, trial_user.id])) + + ActiveJob::Base.queue_adapter.enqueued_jobs.clear + end + + it 'processes all active and trial users' do + expect { described_class.perform_now }.to \ + have_enqueued_job(Tracks::ParallelGeneratorJob).twice + end + + it 'does not process inactive users' do + # Clear points and tracks to make destruction possible + Point.destroy_all + Track.destroy_all + + # Remove active and trial users to isolate test + active_user.destroy + trial_user.destroy + + expect do + described_class.perform_now + end.not_to have_enqueued_job(Tracks::ParallelGeneratorJob) + end + + it 'enqueues correct number of parallel generation jobs for users with new points' do + expect { described_class.perform_now }.to \ + have_enqueued_job(Tracks::ParallelGeneratorJob).exactly(2).times + end + + it 'enqueues parallel generation job for active user with correct parameters' do + expect { described_class.perform_now }.to \ + have_enqueued_job(Tracks::ParallelGeneratorJob).with( + active_user.id, + hash_including(mode: 'daily') + ) + end + + it 'enqueues parallel generation job for trial user' do + expect { described_class.perform_now }.to \ + have_enqueued_job(Tracks::ParallelGeneratorJob).with( + trial_user.id, + hash_including(mode: 'daily') + ) + end + + it 'does not enqueue jobs for users without new points' do + Point.destroy_all + + expect { described_class.perform_now }.not_to \ + have_enqueued_job(Tracks::ParallelGeneratorJob) + end + + context 'when processing fails' do + before do + allow_any_instance_of(User).to receive(:tracks).and_raise(StandardError, 'Database error') + allow(ExceptionReporter).to receive(:call) + + active_user.update!(points_count: 5) + trial_user.update!(points_count: 3) + end + it 'does not raise errors when processing fails' do + expect { described_class.perform_now }.not_to raise_error + end + + it 'reports exceptions when processing fails' do + described_class.perform_now + + expect(ExceptionReporter).to have_received(:call).at_least(:once) + end + end + + context 'when user has no points' do + let!(:empty_user) { create(:user) } + + it 'skips users with no points' do + expect { described_class.perform_now }.not_to \ + have_enqueued_job(Tracks::ParallelGeneratorJob).with(empty_user.id, any_args) + end + end + + context 'when user has tracks but no new points' do + let!(:user_with_current_tracks) { create(:user) } + let!(:recent_points) { create_list(:point, 2, user: user_with_current_tracks, timestamp: 1.hour.ago.to_i) } + let!(:recent_track) do + create(:track, user: user_with_current_tracks, start_at: 1.hour.ago, end_at: 30.minutes.ago) + end + + before do + user_with_current_tracks.update!(points_count: user_with_current_tracks.points.count) + end + + it 'skips users without new points since last track' do + expect { described_class.perform_now }.not_to \ + have_enqueued_job(Tracks::ParallelGeneratorJob).with(user_with_current_tracks.id, any_args) + end + end + end +end diff --git a/spec/jobs/tracks/incremental_check_job_spec.rb b/spec/jobs/tracks/incremental_check_job_spec.rb deleted file mode 100644 index c25d1299..00000000 --- a/spec/jobs/tracks/incremental_check_job_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Tracks::IncrementalCheckJob, type: :job do - let(:user) { create(:user) } - let(:point) { create(:point, user: user) } - - describe '#perform' do - context 'with valid parameters' do - let(:processor) { instance_double(Tracks::IncrementalProcessor) } - - it 'calls the incremental processor' do - expect(Tracks::IncrementalProcessor).to receive(:new) - .with(user, point) - .and_return(processor) - - expect(processor).to receive(:call) - - described_class.new.perform(user.id, point.id) - end - end - end - - describe 'job configuration' do - it 'uses tracks queue' do - expect(described_class.queue_name).to eq('tracks') - end - end - - describe 'integration with ActiveJob' do - it 'enqueues the job' do - expect do - described_class.perform_later(user.id, point.id) - end.to have_enqueued_job(described_class) - .with(user.id, point.id) - end - end -end diff --git a/spec/jobs/tracks/parallel_generator_job_spec.rb b/spec/jobs/tracks/parallel_generator_job_spec.rb new file mode 100644 index 00000000..75c34738 --- /dev/null +++ b/spec/jobs/tracks/parallel_generator_job_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::ParallelGeneratorJob do + let(:user) { create(:user) } + let(:job) { described_class.new } + + before do + Rails.cache.clear + # Stub user settings that might be called during point creation or track processing + allow_any_instance_of(User).to receive_message_chain(:safe_settings, :minutes_between_routes).and_return(30) + allow_any_instance_of(User).to receive_message_chain(:safe_settings, :meters_between_routes).and_return(500) + allow_any_instance_of(User).to receive_message_chain(:safe_settings, :live_map_enabled).and_return(false) + end + + describe 'queue configuration' do + it 'uses the tracks queue' do + expect(described_class.queue_name).to eq('tracks') + end + end + + describe '#perform' do + let(:user_id) { user.id } + let(:options) { {} } + + context 'with successful execution' do + let!(:point1) { create(:point, user: user, timestamp: 2.days.ago.to_i) } + let!(:point2) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + it 'calls Tracks::ParallelGenerator with correct parameters' do + expect(Tracks::ParallelGenerator).to receive(:new) + .with(user, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day) + .and_call_original + + job.perform(user_id) + end + + it 'accepts custom parameters' do + start_at = 1.week.ago + end_at = Time.current + mode = :daily + chunk_size = 2.days + + expect(Tracks::ParallelGenerator).to receive(:new) + .with(user, start_at: start_at, end_at: end_at, mode: mode, chunk_size: chunk_size) + .and_call_original + + job.perform(user_id, start_at: start_at, end_at: end_at, mode: mode, chunk_size: chunk_size) + end + end + + context 'when an error occurs' do + let(:error_message) { 'Something went wrong' } + + before do + allow(Tracks::ParallelGenerator).to receive(:new).and_raise(StandardError.new(error_message)) + end + + it 'reports the exception' do + expect(ExceptionReporter).to receive(:call) + .with(kind_of(StandardError), 'Failed to start parallel track generation') + + job.perform(user_id) + end + end + + context 'with different modes' do + let!(:point) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + it 'handles bulk mode' do + expect(Tracks::ParallelGenerator).to receive(:new) + .with(user, start_at: nil, end_at: nil, mode: :bulk, chunk_size: 1.day) + .and_call_original + + job.perform(user_id, mode: :bulk) + end + + it 'handles incremental mode' do + expect(Tracks::ParallelGenerator).to receive(:new) + .with(user, start_at: nil, end_at: nil, mode: :incremental, chunk_size: 1.day) + .and_call_original + + job.perform(user_id, mode: :incremental) + end + + it 'handles daily mode' do + start_at = Date.current + expect(Tracks::ParallelGenerator).to receive(:new) + .with(user, start_at: start_at, end_at: nil, mode: :daily, chunk_size: 1.day) + .and_call_original + + job.perform(user_id, start_at: start_at, mode: :daily) + end + end + + context 'with time ranges' do + let!(:point) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + let(:start_at) { 1.week.ago } + let(:end_at) { Time.current } + + it 'passes time range to generator' do + expect(Tracks::ParallelGenerator).to receive(:new) + .with(user, start_at: start_at, end_at: end_at, mode: :bulk, chunk_size: 1.day) + .and_call_original + + job.perform(user_id, start_at: start_at, end_at: end_at) + end + end + + context 'with custom chunk size' do + let!(:point) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + let(:chunk_size) { 6.hours } + + it 'passes chunk size to generator' do + expect(Tracks::ParallelGenerator).to receive(:new) + .with(user, start_at: nil, end_at: nil, mode: :bulk, chunk_size: chunk_size) + .and_call_original + + job.perform(user_id, chunk_size: chunk_size) + end + end + end + + describe 'integration with existing track job patterns' do + let!(:point) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + it 'can be queued and executed' do + expect do + described_class.perform_later(user.id) + end.to have_enqueued_job(described_class).with(user.id) + end + end +end diff --git a/spec/jobs/users/mailer_sending_job_spec.rb b/spec/jobs/users/mailer_sending_job_spec.rb index ba4b1de9..b6b80a9e 100644 --- a/spec/jobs/users/mailer_sending_job_spec.rb +++ b/spec/jobs/users/mailer_sending_job_spec.rb @@ -108,37 +108,13 @@ RSpec.describe Users::MailerSendingJob, type: :job do end context 'when user is deleted' do - it 'raises ActiveRecord::RecordNotFound' do + it 'does not raise an error' do user.destroy - expect { + expect do described_class.perform_now(user.id, 'welcome') - }.to raise_error(ActiveRecord::RecordNotFound) + end.not_to raise_error end end end - - describe '#trial_related_email?' do - subject { described_class.new } - - it 'returns true for trial_expires_soon' do - expect(subject.send(:trial_related_email?, 'trial_expires_soon')).to be true - end - - it 'returns true for trial_expired' do - expect(subject.send(:trial_related_email?, 'trial_expired')).to be true - end - - it 'returns false for welcome' do - expect(subject.send(:trial_related_email?, 'welcome')).to be false - end - - it 'returns false for explore_features' do - expect(subject.send(:trial_related_email?, 'explore_features')).to be false - end - - it 'returns false for unknown email types' do - expect(subject.send(:trial_related_email?, 'unknown_email')).to be false - end - end end diff --git a/spec/lib/json_stream_handler_spec.rb b/spec/lib/json_stream_handler_spec.rb new file mode 100644 index 00000000..97bc7763 --- /dev/null +++ b/spec/lib/json_stream_handler_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' +require 'oj' + +RSpec.describe JsonStreamHandler do + let(:processor) { double('StreamProcessor') } + let(:handler) { described_class.new(processor) } + + let(:payload) do + { + 'counts' => { 'places' => 2, 'visits' => 1, 'points' => 1 }, + 'settings' => { 'theme' => 'dark' }, + 'areas' => [{ 'name' => 'Home' }], + 'places' => [ + { 'name' => 'Cafe', 'latitude' => 1.0, 'longitude' => 2.0 }, + { 'name' => 'Library', 'latitude' => 3.0, 'longitude' => 4.0 } + ], + 'visits' => [ + { + 'name' => 'Morning Coffee', + 'started_at' => '2025-01-01T09:00:00Z', + 'ended_at' => '2025-01-01T10:00:00Z' + } + ], + 'points' => [ + { 'timestamp' => 1, 'lonlat' => 'POINT(2 1)' } + ] + } + end + + before do + allow(processor).to receive(:handle_section) + allow(processor).to receive(:handle_stream_value) + allow(processor).to receive(:finish_stream) + end + + it 'streams configured sections and delegates other values immediately' do + Oj.saj_parse(handler, Oj.dump(payload, mode: :compat)) + + expect(processor).to have_received(:handle_section).with('counts', hash_including('places' => 2)) + expect(processor).to have_received(:handle_section).with('settings', hash_including('theme' => 'dark')) + expect(processor).to have_received(:handle_section).with('areas', [hash_including('name' => 'Home')]) + + expect(processor).to have_received(:handle_stream_value).with('places', hash_including('name' => 'Cafe')) + expect(processor).to have_received(:handle_stream_value).with('places', hash_including('name' => 'Library')) + expect(processor).to have_received(:handle_stream_value).with('visits', hash_including('name' => 'Morning Coffee')) + expect(processor).to have_received(:handle_stream_value).with('points', hash_including('timestamp' => 1)) + + expect(processor).to have_received(:finish_stream).with('places') + expect(processor).to have_received(:finish_stream).with('visits') + expect(processor).to have_received(:finish_stream).with('points') + + expect(processor).not_to have_received(:handle_section).with('places', anything) + expect(processor).not_to have_received(:handle_section).with('visits', anything) + expect(processor).not_to have_received(:handle_section).with('points', anything) + end +end diff --git a/spec/mailers/users_mailer_spec.rb b/spec/mailers/users_mailer_spec.rb index 11789e2b..558c3c48 100644 --- a/spec/mailers/users_mailer_spec.rb +++ b/spec/mailers/users_mailer_spec.rb @@ -1,51 +1,69 @@ # frozen_string_literal: true -require "rails_helper" +require 'rails_helper' RSpec.describe UsersMailer, type: :mailer do - let(:user) { create(:user, email: 'test@example.com') } + let(:user) { create(:user) } before do stub_const('ENV', ENV.to_hash.merge('SMTP_FROM' => 'hi@dawarich.app')) end - describe "welcome" do + describe 'welcome' do let(:mail) { UsersMailer.with(user: user).welcome } - it "renders the headers" do - expect(mail.subject).to eq("Welcome to Dawarich!") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('Welcome to Dawarich!') + expect(mail.to).to eq([user.email]) end - it "renders the body" do - expect(mail.body.encoded).to match("test@example.com") + it 'renders the body' do + expect(mail.body.encoded).to match(user.email) end end - describe "explore_features" do + describe 'explore_features' do let(:mail) { UsersMailer.with(user: user).explore_features } - it "renders the headers" do - expect(mail.subject).to eq("Explore Dawarich features!") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('Explore Dawarich features!') + expect(mail.to).to eq([user.email]) end end - describe "trial_expires_soon" do + describe 'trial_expires_soon' do let(:mail) { UsersMailer.with(user: user).trial_expires_soon } - it "renders the headers" do - expect(mail.subject).to eq("⚠️ Your Dawarich trial expires in 2 days") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('⚠️ Your Dawarich trial expires in 2 days') + expect(mail.to).to eq([user.email]) end end - describe "trial_expired" do + describe 'trial_expired' do let(:mail) { UsersMailer.with(user: user).trial_expired } - it "renders the headers" do - expect(mail.subject).to eq("💔 Your Dawarich trial expired") - expect(mail.to).to eq(["test@example.com"]) + it 'renders the headers' do + expect(mail.subject).to eq('💔 Your Dawarich trial expired') + expect(mail.to).to eq([user.email]) + end + end + + describe 'post_trial_reminder_early' do + let(:mail) { UsersMailer.with(user: user).post_trial_reminder_early } + + it 'renders the headers' do + expect(mail.subject).to eq('🚀 Still interested in Dawarich? Subscribe now!') + expect(mail.to).to eq([user.email]) + end + end + + describe 'post_trial_reminder_late' do + let(:mail) { UsersMailer.with(user: user).post_trial_reminder_late } + + it 'renders the headers' do + expect(mail.subject).to eq('📍 Your location data is waiting - Subscribe to Dawarich') + expect(mail.to).to eq([user.email]) end end end diff --git a/spec/models/family/invitation_spec.rb b/spec/models/family/invitation_spec.rb new file mode 100644 index 00000000..2abd5db4 --- /dev/null +++ b/spec/models/family/invitation_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::Invitation, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:family) } + it { is_expected.to belong_to(:invited_by).class_name('User') } + end + + describe 'validations' do + subject { build(:family_invitation) } + + it { is_expected.to validate_presence_of(:email) } + it { is_expected.to allow_value('test@example.com').for(:email) } + it { is_expected.not_to allow_value('invalid-email').for(:email) } + it { is_expected.to validate_uniqueness_of(:token) } + it { is_expected.to validate_presence_of(:status) } + + it 'validates token presence after creation' do + invitation = build(:family_invitation, token: nil) + invitation.save + expect(invitation.token).to be_present + end + + it 'validates expires_at presence after creation' do + invitation = build(:family_invitation, expires_at: nil) + invitation.save + expect(invitation.expires_at).to be_present + end + end + + describe 'enums' do + it { is_expected.to define_enum_for(:status).with_values(pending: 0, accepted: 1, expired: 2, cancelled: 3) } + end + + describe 'scopes' do + let(:family) { create(:family) } + let(:pending_invitation) do + create(:family_invitation, family: family, status: :pending, expires_at: 1.day.from_now) + end + let(:expired_invitation) { create(:family_invitation, family: family, status: :pending, expires_at: 1.day.ago) } + let(:accepted_invitation) { create(:family_invitation, :accepted, family: family) } + + describe '.active' do + it 'returns only pending and non-expired invitations' do + expect(Family::Invitation.active).to include(pending_invitation) + expect(Family::Invitation.active).not_to include(expired_invitation) + expect(Family::Invitation.active).not_to include(accepted_invitation) + end + end + end + + describe 'callbacks' do + describe 'before_validation on create' do + let(:invitation) { build(:family_invitation, token: nil, expires_at: nil) } + + it 'generates a token' do + invitation.save + expect(invitation.token).to be_present + expect(invitation.token.length).to be > 20 + end + + it 'sets expiry date' do + invitation.save + expect(invitation.expires_at).to be_within(1.minute).of(Family::Invitation::EXPIRY_DAYS.days.from_now) + end + + it 'does not override existing token' do + custom_token = 'custom-token' + invitation.token = custom_token + invitation.save + expect(invitation.token).to eq(custom_token) + end + + it 'does not override existing expiry date' do + custom_expiry = 3.days.from_now + invitation.expires_at = custom_expiry + invitation.save + expect(invitation.expires_at).to be_within(1.second).of(custom_expiry) + end + end + end + + describe '#expired?' do + context 'when expires_at is in the future' do + let(:invitation) { create(:family_invitation, expires_at: 1.day.from_now) } + + it 'returns false' do + expect(invitation.expired?).to be false + end + end + + context 'when expires_at is in the past' do + let(:invitation) { create(:family_invitation, expires_at: 1.day.ago) } + + it 'returns true' do + expect(invitation.expired?).to be true + end + end + end + + describe '#can_be_accepted?' do + context 'when invitation is pending and not expired' do + let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.from_now) } + + it 'returns true' do + expect(invitation.can_be_accepted?).to be true + end + end + + context 'when invitation is pending but expired' do + let(:invitation) { create(:family_invitation, status: :pending, expires_at: 1.day.ago) } + + it 'returns false' do + expect(invitation.can_be_accepted?).to be false + end + end + + context 'when invitation is accepted' do + let(:invitation) { create(:family_invitation, :accepted, expires_at: 1.day.from_now) } + + it 'returns false' do + expect(invitation.can_be_accepted?).to be false + end + end + + context 'when invitation is cancelled' do + let(:invitation) { create(:family_invitation, :cancelled, expires_at: 1.day.from_now) } + + it 'returns false' do + expect(invitation.can_be_accepted?).to be false + end + end + end + + describe 'constants' do + it 'defines EXPIRY_DAYS' do + expect(Family::Invitation::EXPIRY_DAYS).to eq(7) + end + end + + describe 'token uniqueness' do + let(:family) { create(:family) } + let(:user) { create(:user) } + + it 'ensures each invitation has a unique token' do + invitation1 = create(:family_invitation, family: family, invited_by: user) + invitation2 = create(:family_invitation, family: family, invited_by: user) + + expect(invitation1.token).not_to eq(invitation2.token) + end + end + + describe 'email format validation' do + let(:invitation) { build(:family_invitation) } + + it 'accepts valid email formats' do + valid_emails = ['test@example.com', 'user.name@domain.co.uk', 'user+tag@example.org'] + + valid_emails.each do |email| + invitation.email = email + expect(invitation).to be_valid + end + end + + it 'rejects invalid email formats' do + invalid_emails = ['invalid-email', '@example.com', 'user@', 'user space@example.com'] + + invalid_emails.each do |email| + invitation.email = email + expect(invitation).not_to be_valid + expect(invitation.errors[:email]).to be_present + end + end + end +end diff --git a/spec/models/family/membership_spec.rb b/spec/models/family/membership_spec.rb new file mode 100644 index 00000000..0cc859e7 --- /dev/null +++ b/spec/models/family/membership_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::Membership, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:family) } + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + subject { build(:family_membership) } + + it { is_expected.to validate_presence_of(:user_id) } + it { is_expected.to validate_uniqueness_of(:user_id) } + it { is_expected.to validate_presence_of(:role) } + end + + describe 'enums' do + it { is_expected.to define_enum_for(:role).with_values(owner: 0, member: 1) } + end + + describe 'one family per user constraint' do + let(:user) { create(:user) } + let(:family1) { create(:family) } + let(:family2) { create(:family) } + + it 'allows a user to be in one family' do + membership1 = build(:family_membership, family: family1, user: user) + expect(membership1).to be_valid + end + + it 'prevents a user from being in multiple families' do + create(:family_membership, family: family1, user: user) + membership2 = build(:family_membership, family: family2, user: user) + + expect(membership2).not_to be_valid + expect(membership2.errors[:user_id]).to include('has already been taken') + end + end + + describe 'role assignment' do + let(:family) { create(:family) } + + context 'when created as owner' do + let(:membership) { create(:family_membership, :owner, family: family) } + + it 'can be created' do + expect(membership.role).to eq('owner') + expect(membership.owner?).to be true + end + end + + context 'when created as member' do + let(:membership) { create(:family_membership, family: family, role: :member) } + + it 'can be created' do + expect(membership.role).to eq('member') + expect(membership.member?).to be true + end + end + + it 'defaults to member role' do + membership = create(:family_membership, family: family) + expect(membership.role).to eq('member') + end + end +end diff --git a/spec/models/family_spec.rb b/spec/models/family_spec.rb new file mode 100644 index 00000000..7f81b898 --- /dev/null +++ b/spec/models/family_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family, type: :model do + let(:user) { create(:user) } + + describe 'associations' do + it { is_expected.to have_many(:family_memberships).dependent(:destroy) } + it { is_expected.to have_many(:members).through(:family_memberships).source(:user) } + it { is_expected.to have_many(:family_invitations).dependent(:destroy) } + it { is_expected.to belong_to(:creator).class_name('User') } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(50) } + end + + describe 'constants' do + it 'defines MAX_MEMBERS' do + expect(Family::MAX_MEMBERS).to eq(5) + end + end + + describe '#can_add_members?' do + let(:family) { create(:family, creator: user) } + + context 'when family has fewer than max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 3, family: family, role: :member) + end + + it 'returns true' do + expect(family.can_add_members?).to be true + end + end + + context 'when family has max members' do + before do + create(:family_membership, family: family, user: user, role: :owner) + create_list(:family_membership, 4, family: family, role: :member) + end + + it 'returns false' do + expect(family.can_add_members?).to be false + end + end + + context 'when family has no members' do + it 'returns true' do + expect(family.can_add_members?).to be true + end + end + end + + describe 'family creation' do + let(:family) { Family.new(name: 'Test Family', creator: user) } + + it 'can be created with valid attributes' do + expect(family).to be_valid + end + + it 'requires a name' do + family.name = nil + + expect(family).not_to be_valid + expect(family.errors[:name]).to include("can't be blank") + end + + it 'requires a creator' do + family.creator = nil + + expect(family).not_to be_valid + end + + it 'rejects names longer than 50 characters' do + long_name = 'a' * 51 + family.name = long_name + + expect(family).not_to be_valid + expect(family.errors[:name]).to include('is too long (maximum is 50 characters)') + end + end + + describe 'members association' do + let(:family) { create(:family, creator: user) } + let(:member1) { create(:user) } + let(:member2) { create(:user) } + + before do + create(:family_membership, family: family, user: user, role: :owner) + create(:family_membership, family: family, user: member1, role: :member) + create(:family_membership, family: family, user: member2, role: :member) + end + + it 'includes all family members' do + expect(family.members).to include(user, member1, member2) + expect(family.members.count).to eq(3) + end + end + + describe 'family invitations association' do + let(:family) { create(:family, creator: user) } + + it 'destroys associated invitations when family is destroyed' do + invitation = create(:family_invitation, family: family, invited_by: user) + + expect { family.destroy }.to change(Family::Invitation, :count).by(-1) + expect(Family::Invitation.find_by(id: invitation.id)).to be_nil + end + end + + describe 'family memberships association' do + let(:family) { create(:family, creator: user) } + + it 'destroys associated memberships when family is destroyed' do + membership = create(:family_membership, family: family, user: user, role: :owner) + + expect { family.destroy }.to change(Family::Membership, :count).by(-1) + expect(Family::Membership.find_by(id: membership.id)).to be_nil + end + end +end diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb index 50034082..2a90c2aa 100644 --- a/spec/models/import_spec.rb +++ b/spec/models/import_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Import, type: :model do describe 'file size validation' do context 'when user is a trial user' do - let(:user) do + let(:user) do user = create(:user) user.update!(status: :trial) user @@ -66,6 +66,42 @@ RSpec.describe Import, type: :model do end end end + + describe 'import count validation' do + context 'when user is a trial user' do + let(:user) do + user = create(:user) + user.update!(status: :trial) + user + end + + it 'allows imports when under the limit' do + 3.times { |i| create(:import, user: user, name: "import_#{i}") } + new_import = build(:import, user: user, name: 'new_import') + + expect(new_import).to be_valid + end + + it 'prevents creating more than 5 imports' do + 5.times { |i| create(:import, user: user, name: "import_#{i}") } + new_import = build(:import, user: user, name: 'import_6') + + expect(new_import).not_to be_valid + expect(new_import.errors[:base]).to include('Trial users can only create up to 5 imports. Please subscribe to import more files.') + end + end + + context 'when user is an active user' do + let(:user) { create(:user, status: :active) } + + it 'does not validate import count limit' do + 7.times { |i| create(:import, user: user, name: "import_#{i}") } + new_import = build(:import, user: user, name: 'import_8') + + expect(new_import).to be_valid + end + end + end end describe 'enums' do @@ -116,4 +152,28 @@ RSpec.describe Import, type: :model do end end end + + describe '#recalculate_stats' do + let(:import) { create(:import, user:) } + let!(:point1) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 11, 15).to_i) } + let!(:point2) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 12, 5).to_i) } + + it 'enqueues stats calculation jobs for each tracked month' do + expect do + import.send(:recalculate_stats) + end.to have_enqueued_job(Stats::CalculatingJob) + .with(user.id, 2024, 11) + .and have_enqueued_job(Stats::CalculatingJob).with(user.id, 2024, 12) + end + + context 'when import has no points' do + let(:empty_import) { create(:import, user:) } + + it 'does not enqueue any jobs' do + expect do + empty_import.send(:recalculate_stats) + end.not_to have_enqueued_job(Stats::CalculatingJob) + end + end + end end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index 41c3587e..b55f761a 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -23,7 +23,12 @@ RSpec.describe Notification, type: :model do let(:unread_notification) { create(:notification, read_at: nil) } it 'returns only unread notifications' do - expect(described_class.unread).to eq([unread_notification]) + read_notification # ensure it's created + unread_notification # ensure it's created + + unread_notifications = described_class.unread + expect(unread_notifications).to include(unread_notification) + expect(unread_notifications).not_to include(read_notification) end end end diff --git a/spec/models/point_spec.rb b/spec/models/point_spec.rb index eaf3d4ba..a61246f4 100644 --- a/spec/models/point_spec.rb +++ b/spec/models/point_spec.rb @@ -53,11 +53,17 @@ RSpec.describe Point, type: :model do end describe '.not_reverse_geocoded' do - let(:point) { create(:point, country: 'Country', city: 'City') } - let(:point_without_address) { create(:point, city: nil, country: nil) } + let!(:point) { create(:point, country: 'Country', city: 'City', reverse_geocoded_at: Time.current) } + let!(:point_without_address) { create(:point, city: nil, country: nil, reverse_geocoded_at: nil) } it 'returns points without reverse geocoded address' do - expect(described_class.not_reverse_geocoded).to eq([point_without_address]) + # Trigger creation of both points + point + point_without_address + + result = described_class.not_reverse_geocoded + expect(result).to include(point_without_address) + expect(result).not_to include(point) end end end diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb index ee4c477f..6b70b59b 100644 --- a/spec/models/stat_spec.rb +++ b/spec/models/stat_spec.rb @@ -102,5 +102,383 @@ RSpec.describe Stat, type: :model do expect(subject).to eq(points) end end + + describe '#calculate_data_bounds' do + let(:stat) { create(:stat, year: 2024, month: 6, user:) } + let(:user) { create(:user) } + + context 'when stat has points' do + before do + # Create test points within the month (June 2024) + create(:point, + user:, + latitude: 40.6, + longitude: -74.1, + timestamp: Time.new(2024, 6, 1, 12, 0).to_i) + create(:point, + user:, + latitude: 40.8, + longitude: -73.9, + timestamp: Time.new(2024, 6, 15, 15, 0).to_i) + create(:point, + user:, + latitude: 40.7, + longitude: -74.0, + timestamp: Time.new(2024, 6, 30, 18, 0).to_i) + + # Points outside the month (should be ignored) + create(:point, + user:, + latitude: 41.0, + longitude: -75.0, + timestamp: Time.new(2024, 5, 31, 23, 59).to_i) # May + create(:point, + user:, + latitude: 39.0, + longitude: -72.0, + timestamp: Time.new(2024, 7, 1, 0, 1).to_i) # July + end + + it 'returns correct bounding box for points within the month' do + result = stat.calculate_data_bounds + + expect(result).to be_a(Hash) + expect(result[:min_lat]).to eq(40.6) + expect(result[:max_lat]).to eq(40.8) + expect(result[:min_lng]).to eq(-74.1) + expect(result[:max_lng]).to eq(-73.9) + expect(result[:point_count]).to eq(3) + end + + context 'with points from different users' do + let(:other_user) { create(:user) } + + before do + # Add points from a different user (should be ignored) + create(:point, + user: other_user, + latitude: 50.0, + longitude: -80.0, + timestamp: Time.new(2024, 6, 15, 12, 0).to_i) + end + + it 'only includes points from the stat user' do + result = stat.calculate_data_bounds + + expect(result[:min_lat]).to eq(40.6) + expect(result[:max_lat]).to eq(40.8) + expect(result[:min_lng]).to eq(-74.1) + expect(result[:max_lng]).to eq(-73.9) + expect(result[:point_count]).to eq(3) # Still only 3 points from the stat user + end + end + + context 'with single point' do + let(:single_point_user) { create(:user) } + let(:single_point_stat) { create(:stat, year: 2024, month: 7, user: single_point_user) } + + before do + create(:point, + user: single_point_user, + latitude: 45.5, + longitude: -122.65, + timestamp: Time.new(2024, 7, 15, 14, 30).to_i) + end + + it 'returns bounds with same min and max values' do + result = single_point_stat.calculate_data_bounds + + expect(result[:min_lat]).to eq(45.5) + expect(result[:max_lat]).to eq(45.5) + expect(result[:min_lng]).to eq(-122.65) + expect(result[:max_lng]).to eq(-122.65) + expect(result[:point_count]).to eq(1) + end + end + + context 'with edge case coordinates' do + let(:edge_user) { create(:user) } + let(:edge_stat) { create(:stat, year: 2024, month: 8, user: edge_user) } + + before do + # Test with extreme coordinate values + create(:point, + user: edge_user, + latitude: -90.0, # South Pole + longitude: -180.0, # Date Line West + timestamp: Time.new(2024, 8, 1, 0, 0).to_i) + create(:point, + user: edge_user, + latitude: 90.0, # North Pole + longitude: 180.0, # Date Line East + timestamp: Time.new(2024, 8, 31, 23, 59).to_i) + end + + it 'handles extreme coordinate values correctly' do + result = edge_stat.calculate_data_bounds + + expect(result[:min_lat]).to eq(-90.0) + expect(result[:max_lat]).to eq(90.0) + expect(result[:min_lng]).to eq(-180.0) + expect(result[:max_lng]).to eq(180.0) + expect(result[:point_count]).to eq(2) + end + end + end + + context 'when stat has no points' do + let(:empty_user) { create(:user) } + let(:empty_stat) { create(:stat, year: 2024, month: 10, user: empty_user) } + + it 'returns nil' do + result = empty_stat.calculate_data_bounds + + expect(result).to be_nil + end + end + + context 'when stat has points but none within the month timeframe' do + let(:empty_month_user) { create(:user) } + let(:empty_month_stat) { create(:stat, year: 2024, month: 9, user: empty_month_user) } + + before do + # Create points outside the target month + create(:point, + user: empty_month_user, + latitude: 40.7, + longitude: -74.0, + timestamp: Time.new(2024, 8, 31, 23, 59).to_i) # August + create(:point, + user: empty_month_user, + latitude: 40.8, + longitude: -73.9, + timestamp: Time.new(2024, 10, 1, 0, 1).to_i) # October + end + + it 'returns nil when no points exist in the month' do + result = empty_month_stat.calculate_data_bounds + + expect(result).to be_nil + end + end + end + + describe 'sharing settings' do + let(:user) { create(:user) } + let(:stat) { create(:stat, year: 2024, month: 6, user: user) } + + describe '#sharing_enabled?' do + context 'when sharing_settings is nil' do + before { stat.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(stat.sharing_enabled?).to be false + end + end + + context 'when sharing_settings is empty hash' do + before { stat.update(sharing_settings: {}) } + + it 'returns false' do + expect(stat.sharing_enabled?).to be false + end + end + + context 'when enabled is false' do + before { stat.update(sharing_settings: { 'enabled' => false }) } + + it 'returns false' do + expect(stat.sharing_enabled?).to be false + end + end + + context 'when enabled is true' do + before { stat.update(sharing_settings: { 'enabled' => true }) } + + it 'returns true' do + expect(stat.sharing_enabled?).to be true + end + end + + context 'when enabled is a string "true"' do + before { stat.update(sharing_settings: { 'enabled' => 'true' }) } + + it 'returns false (strict boolean check)' do + expect(stat.sharing_enabled?).to be false + end + end + end + + describe '#sharing_expired?' do + context 'when sharing_settings is nil' do + before { stat.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(stat.sharing_expired?).to be false + end + end + + context 'when expiration is blank' do + before { stat.update(sharing_settings: { 'enabled' => true }) } + + it 'returns false' do + expect(stat.sharing_expired?).to be false + end + end + + context 'when expiration is present but expires_at is blank' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h' + }) + end + + it 'returns true' do + expect(stat.sharing_expired?).to be true + end + end + + context 'when expires_at is in the future' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.from_now.iso8601 + }) + end + + it 'returns false' do + expect(stat.sharing_expired?).to be false + end + end + + context 'when expires_at is in the past' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + }) + end + + it 'returns true' do + expect(stat.sharing_expired?).to be true + end + end + + context 'when expires_at is 1 second in the future' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.second.from_now.iso8601 + }) + end + + it 'returns false (not yet expired)' do + expect(stat.sharing_expired?).to be false + end + end + + context 'when expires_at is invalid date string' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 'invalid-date' + }) + end + + it 'returns true (treats as expired)' do + expect(stat.sharing_expired?).to be true + end + end + + context 'when expires_at is nil' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => nil + }) + end + + it 'returns true' do + expect(stat.sharing_expired?).to be true + end + end + + context 'when expires_at is empty string' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => '' + }) + end + + it 'returns true' do + expect(stat.sharing_expired?).to be true + end + end + end + + describe '#public_accessible?' do + context 'when sharing_settings is nil' do + before { stat.update_column(:sharing_settings, nil) } + + it 'returns false' do + expect(stat.public_accessible?).to be false + end + end + + context 'when sharing is not enabled' do + before { stat.update(sharing_settings: { 'enabled' => false }) } + + it 'returns false' do + expect(stat.public_accessible?).to be false + end + end + + context 'when sharing is enabled but expired' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.ago.iso8601 + }) + end + + it 'returns false' do + expect(stat.public_accessible?).to be false + end + end + + context 'when sharing is enabled and not expired' do + before do + stat.update(sharing_settings: { + 'enabled' => true, + 'expiration' => '1h', + 'expires_at' => 1.hour.from_now.iso8601 + }) + end + + it 'returns true' do + expect(stat.public_accessible?).to be true + end + end + + context 'when sharing is enabled with no expiration' do + before do + stat.update(sharing_settings: { 'enabled' => true }) + end + + it 'returns true' do + expect(stat.public_accessible?).to be true + end + end + end + end end end diff --git a/spec/models/user_family_spec.rb b/spec/models/user_family_spec.rb new file mode 100644 index 00000000..0a0d2879 --- /dev/null +++ b/spec/models/user_family_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe User, 'family methods', type: :model do + let(:user) { create(:user) } + + describe 'family associations' do + it { is_expected.to have_one(:family_membership).dependent(:destroy) } + it { is_expected.to have_one(:family).through(:family_membership) } + it { + is_expected.to have_one(:created_family).class_name('Family').with_foreign_key('creator_id').dependent(:destroy) + } + it { + is_expected.to have_many(:sent_family_invitations).class_name('Family::Invitation').with_foreign_key('invited_by_id').dependent(:destroy) + } + end + + describe '#in_family?' do + context 'when user has no family membership' do + it 'returns false' do + expect(user.in_family?).to be false + end + end + + context 'when user has family membership' do + let(:family) { create(:family, creator: user) } + + before do + create(:family_membership, user: user, family: family) + end + + it 'returns true' do + expect(user.in_family?).to be true + end + end + end + + describe '#family_owner?' do + let(:family) { create(:family, creator: user) } + + context 'when user is family owner' do + before do + create(:family_membership, user: user, family: family, role: :owner) + end + + it 'returns true' do + expect(user.family_owner?).to be true + end + end + + context 'when user is family member' do + before do + create(:family_membership, user: user, family: family, role: :member) + end + + it 'returns false' do + expect(user.family_owner?).to be false + end + end + + context 'when user has no family membership' do + it 'returns false' do + expect(user.family_owner?).to be false + end + end + end + + describe '#can_delete_account?' do + context 'when user is not a family owner' do + it 'returns true' do + expect(user.can_delete_account?).to be true + end + end + + context 'when user is family owner with only themselves as member' do + let(:family) { create(:family, creator: user) } + + before do + create(:family_membership, user: user, family: family, role: :owner) + end + + it 'returns true' do + expect(user.can_delete_account?).to be true + end + end + + context 'when user is family owner with other members' do + let(:family) { create(:family, creator: user) } + let(:other_user) { create(:user) } + + before do + create(:family_membership, user: user, family: family, role: :owner) + create(:family_membership, user: other_user, family: family, role: :member) + end + + it 'returns false' do + expect(user.can_delete_account?).to be false + end + end + end + + describe 'dependent destroy behavior' do + let(:family) { create(:family, creator: user) } + + context 'when user has created families' do + it 'prevents deletion when family has members' do + other_user = create(:user) + create(:family_membership, user: user, family: family, role: :owner) + create(:family_membership, user: other_user, family: family, role: :member) + + expect(user.can_delete_account?).to be false + end + end + + context 'when user has sent invitations' do + before do + create(:family_invitation, family: family, invited_by: user) + end + + it 'destroys associated invitations when user is destroyed' do + expect { user.destroy }.to change(Family::Invitation, :count).by(-1) + end + end + + context 'when user has family membership' do + before do + create(:family_membership, user: user, family: family) + end + + it 'destroys associated membership when user is destroyed' do + expect { user.destroy }.to change(Family::Membership, :count).by(-1) + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 2c07580a..928df596 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5,9 +5,8 @@ require 'rails_helper' RSpec.describe User, type: :model do describe 'associations' do it { is_expected.to have_many(:imports).dependent(:destroy) } - it { is_expected.to have_many(:points).through(:imports) } it { is_expected.to have_many(:stats) } - it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) } + it { is_expected.to have_many(:points).class_name('Point').dependent(:destroy) } it { is_expected.to have_many(:exports).dependent(:destroy) } it { is_expected.to have_many(:notifications).dependent(:destroy) } it { is_expected.to have_many(:areas).dependent(:destroy) } @@ -76,6 +75,14 @@ RSpec.describe User, type: :model do expect(Users::TrialWebhookJob).to receive(:perform_later).with(user.id) user.send(:start_trial) end + + it 'schedules welcome emails' do + allow(user).to receive(:schedule_welcome_emails) + + user.send(:start_trial) + + expect(user).to have_received(:schedule_welcome_emails) + end end describe '#schedule_welcome_emails' do @@ -124,7 +131,7 @@ RSpec.describe User, type: :model do end it 'returns true' do - user.tracked_points.destroy_all + user.points.destroy_all expect(user.trial_state?).to be_truthy end @@ -322,5 +329,11 @@ RSpec.describe User, type: :model do expect { user.export_data }.to have_enqueued_job(Users::ExportDataJob).with(user.id) end end + + describe '#timezone' do + it 'returns the app timezone' do + expect(user.timezone).to eq(Time.zone.name) + end + end end end diff --git a/spec/models/visit_spec.rb b/spec/models/visit_spec.rb index 563d4370..edff03a3 100644 --- a/spec/models/visit_spec.rb +++ b/spec/models/visit_spec.rb @@ -10,6 +10,21 @@ RSpec.describe Visit, type: :model do it { is_expected.to have_many(:points).dependent(:nullify) } end + describe 'validations' do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:started_at) } + it { is_expected.to validate_presence_of(:ended_at) } + it { is_expected.to validate_presence_of(:duration) } + it { is_expected.to validate_presence_of(:status) } + + it 'validates ended_at is greater than started_at' do + visit = build(:visit, started_at: Time.zone.now, ended_at: Time.zone.now - 1.hour) + + expect(visit).not_to be_valid + expect(visit.errors[:ended_at]).to include("must be greater than #{visit.started_at}") + end + end + describe 'factory' do it { expect(build(:visit)).to be_valid } end diff --git a/spec/policies/family/invitation_policy_spec.rb b/spec/policies/family/invitation_policy_spec.rb new file mode 100644 index 00000000..0478c645 --- /dev/null +++ b/spec/policies/family/invitation_policy_spec.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::InvitationPolicy, type: :policy do + let(:family) { create(:family) } + let(:owner) { family.creator } + let(:member) { create(:user) } + let(:other_user) { create(:user) } + let(:invitation) { create(:family_invitation, family: family, invited_by: owner) } + + before do + create(:family_membership, family: family, user: owner, role: :owner) + create(:family_membership, family: family, user: member, role: :member) + end + + describe '#create?' do + context 'when user is family owner' do + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows family owner to create invitations' do + policy = described_class.new(owner, invitation) + + expect(policy).to permit(:create) + end + end + + context 'when user is regular family member' do + before do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + end + + it 'denies regular family member from creating invitations' do + policy = described_class.new(member, invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'when user is not in the family' do + it 'denies user not in the family from creating invitations' do + policy = described_class.new(other_user, invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from creating invitations' do + policy = described_class.new(nil, invitation) + + expect(policy).not_to permit(:create) + end + end + end + + describe '#accept?' do + context 'when user email matches invitation email' do + let(:invited_user) { create(:user, email: invitation.email) } + + it 'allows user to accept invitation sent to their email' do + policy = described_class.new(invited_user, invitation) + + expect(policy).to permit(:accept) + end + end + + context 'when user email does not match invitation email' do + it 'denies user with different email from accepting invitation' do + policy = described_class.new(other_user, invitation) + + expect(policy).not_to permit(:accept) + end + end + + context 'when family owner tries to accept invitation' do + it 'denies family owner from accepting invitation sent to different email' do + policy = described_class.new(owner, invitation) + + expect(policy).not_to permit(:accept) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from accepting invitation' do + policy = described_class.new(nil, invitation) + + expect(policy).not_to permit(:accept) + end + end + end + + describe '#destroy?' do + context 'when user is family owner' do + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows family owner to cancel invitations' do + policy = described_class.new(owner, invitation) + + expect(policy).to permit(:destroy) + end + end + + context 'when user is regular family member' do + before do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + end + + it 'denies regular family member from cancelling invitations' do + policy = described_class.new(member, invitation) + + expect(policy).not_to permit(:destroy) + end + end + + context 'when user is not in the family' do + it 'denies user not in the family from cancelling invitations' do + policy = described_class.new(other_user, invitation) + + expect(policy).not_to permit(:destroy) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from cancelling invitations' do + policy = described_class.new(nil, invitation) + + expect(policy).not_to permit(:destroy) + end + end + end + + describe 'edge cases' do + context 'when invitation belongs to different family' do + let(:other_family) { create(:family) } + let(:other_family_owner) { other_family.creator } + let(:other_invitation) { create(:family_invitation, family: other_family, invited_by: other_family_owner) } + + before do + create(:family_membership, family: other_family, user: other_family_owner, role: :owner) + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'denies owner from creating invitations for different family' do + policy = described_class.new(owner, other_invitation) + + expect(policy).not_to permit(:create) + end + + it 'denies owner from destroying invitations for different family' do + policy = described_class.new(owner, other_invitation) + + expect(policy).not_to permit(:destroy) + end + end + + context 'with expired invitation' do + let(:expired_invitation) { create(:family_invitation, :expired, family: family, invited_by: owner) } + let(:invited_user) { create(:user, email: expired_invitation.email) } + + it 'still allows user to attempt to accept expired invitation (business logic handles expiry)' do + policy = described_class.new(invited_user, expired_invitation) + + expect(policy).to permit(:accept) + end + + it 'allows owner to destroy expired invitation' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, expired_invitation) + + expect(policy).to permit(:destroy) + end + end + + context 'with accepted invitation' do + let(:accepted_invitation) { create(:family_invitation, :accepted, family: family, invited_by: owner) } + + it 'allows owner to destroy accepted invitation' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, accepted_invitation) + + expect(policy).to permit(:destroy) + end + end + + context 'with cancelled invitation' do + let(:cancelled_invitation) { create(:family_invitation, :cancelled, family: family, invited_by: owner) } + + it 'allows owner to destroy cancelled invitation' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, cancelled_invitation) + + expect(policy).to permit(:destroy) + end + end + end + + describe 'authorization consistency' do + it 'ensures owner can both create and destroy invitations' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + policy = described_class.new(owner, invitation) + + expect(policy).to permit(:create) + expect(policy).to permit(:destroy) + end + + it 'ensures regular members cannot create or destroy invitations' do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + policy = described_class.new(member, invitation) + + expect(policy).not_to permit(:create) + expect(policy).not_to permit(:destroy) + end + + it 'ensures invited users can only accept their own invitations' do + invited_user = create(:user, email: invitation.email) + policy = described_class.new(invited_user, invitation) + + expect(policy).to permit(:accept) + expect(policy).not_to permit(:create) + expect(policy).not_to permit(:destroy) + end + end +end diff --git a/spec/policies/family/membership_policy_spec.rb b/spec/policies/family/membership_policy_spec.rb new file mode 100644 index 00000000..b720149a --- /dev/null +++ b/spec/policies/family/membership_policy_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Family::MembershipPolicy, type: :policy do + let(:family) { create(:family) } + let(:owner) { family.creator } + let(:member) { create(:user) } + let(:another_member) { create(:user) } + let(:other_user) { create(:user) } + + let(:owner_membership) { create(:family_membership, :owner, family: family, user: owner) } + let(:member_membership) { create(:family_membership, family: family, user: member) } + let(:another_member_membership) { create(:family_membership, family: family, user: another_member) } + + describe '#create?' do + let(:valid_invitation) { create(:family_invitation, family: family, email: member.email) } + let(:expired_invitation) { create(:family_invitation, family: family, email: member.email, expires_at: 1.day.ago) } + let(:accepted_invitation) { create(:family_invitation, :accepted, family: family, email: member.email) } + let(:wrong_email_invitation) { create(:family_invitation, family: family, email: 'wrong@example.com') } + + context 'when user has valid invitation' do + it 'allows user to create membership with valid pending invitation for their email' do + policy = described_class.new(member, valid_invitation) + + expect(policy).to permit(:create) + end + end + + context 'when invitation is expired' do + it 'denies user from creating membership with expired invitation' do + policy = described_class.new(member, expired_invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'when invitation is already accepted' do + it 'denies user from creating membership with already accepted invitation' do + policy = described_class.new(member, accepted_invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'when invitation is for different email' do + it 'denies user from creating membership with invitation for different email' do + policy = described_class.new(member, wrong_email_invitation) + + expect(policy).not_to permit(:create) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from creating membership' do + policy = described_class.new(nil, valid_invitation) + + expect(policy).not_to permit(:create) + end + end + end + + describe '#destroy?' do + context 'when user is removing themselves' do + it 'allows user to remove their own membership (leave family)' do + allow(member).to receive(:family).and_return(family) + policy = described_class.new(member, member_membership) + + expect(policy).to permit(:destroy) + end + + it 'allows owner to remove their own membership' do + allow(owner).to receive(:family).and_return(family) + policy = described_class.new(owner, owner_membership) + + expect(policy).to permit(:destroy) + end + end + + context 'when user is family owner' do + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows family owner to remove other members' do + policy = described_class.new(owner, member_membership) + + expect(policy).to permit(:destroy) + end + + it 'allows family owner to remove multiple members' do + policy1 = described_class.new(owner, member_membership) + policy2 = described_class.new(owner, another_member_membership) + + expect(policy1).to permit(:destroy) + expect(policy2).to permit(:destroy) + end + end + + context 'when user is regular family member' do + before do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + end + + it 'denies regular member from removing other members' do + policy = described_class.new(member, another_member_membership) + + expect(policy).not_to permit(:destroy) + end + + it 'denies regular member from removing owner' do + policy = described_class.new(member, owner_membership) + + expect(policy).not_to permit(:destroy) + end + end + + context 'when user is not in the family' do + it 'denies user from removing membership of different family' do + policy = described_class.new(other_user, member_membership) + + expect(policy).not_to permit(:destroy) + end + end + + context 'with unauthenticated user' do + it 'denies unauthenticated user from removing membership' do + policy = described_class.new(nil, member_membership) + + expect(policy).not_to permit(:destroy) + end + end + end + + describe 'edge cases' do + context 'when membership belongs to different family' do + let(:other_family) { create(:family) } + let(:other_family_owner) { other_family.creator } + let(:other_family_membership) do + create(:family_membership, :owner, family: other_family, user: other_family_owner) + end + + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'denies owner from destroying membership of different family' do + policy = described_class.new(owner, other_family_membership) + + expect(policy).not_to permit(:destroy) + end + end + + context 'when owner tries to modify another owners membership' do + let(:co_owner) { create(:user) } + let(:co_owner_membership) { create(:family_membership, :owner, family: family, user: co_owner) } + + before do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + end + + it 'allows owner to remove another owner (family owner has full control)' do + policy = described_class.new(owner, co_owner_membership) + + expect(policy).to permit(:destroy) + end + end + end + + describe 'authorization consistency' do + it 'ensures owner can destroy all memberships in their family' do + allow(owner).to receive(:family).and_return(family) + allow(owner).to receive(:family_owner?).and_return(true) + + policy = described_class.new(owner, member_membership) + + expect(policy).to permit(:destroy) + end + + it 'ensures regular members can only remove their own membership' do + allow(member).to receive(:family).and_return(family) + allow(member).to receive(:family_owner?).and_return(false) + + own_policy = described_class.new(member, member_membership) + other_policy = described_class.new(member, another_member_membership) + + # Can remove own membership + expect(own_policy).to permit(:destroy) + + # Cannot remove others + expect(other_policy).not_to permit(:destroy) + end + + it 'ensures users can always leave the family (remove own membership)' do + allow(member).to receive(:family).and_return(family) + policy = described_class.new(member, member_membership) + + expect(policy).to permit(:destroy) + end + end +end diff --git a/spec/queries/stats_query_spec.rb b/spec/queries/stats_query_spec.rb index d4d8517f..8efcbb81 100644 --- a/spec/queries/stats_query_spec.rb +++ b/spec/queries/stats_query_spec.rb @@ -3,6 +3,8 @@ require 'rails_helper' RSpec.describe StatsQuery do + before { Rails.cache.clear } + describe '#points_stats' do subject(:points_stats) { described_class.new(user).points_stats } @@ -11,11 +13,13 @@ RSpec.describe StatsQuery do context 'when user has no points' do it 'returns zero counts for all statistics' do - expect(points_stats).to eq({ - total: 0, - geocoded: 0, - without_data: 0 - }) + expect(points_stats).to eq( + { + total: 0, + geocoded: 0, + without_data: 0 + } + ) end end @@ -45,11 +49,13 @@ RSpec.describe StatsQuery do end it 'returns correct counts for all statistics' do - expect(points_stats).to eq({ - total: 3, - geocoded: 2, - without_data: 1 - }) + expect(points_stats).to eq( + { + total: 3, + geocoded: 2, + without_data: 1 + } + ) end context 'when another user has points' do @@ -64,11 +70,13 @@ RSpec.describe StatsQuery do end it 'only counts points for the specified user' do - expect(points_stats).to eq({ - total: 3, - geocoded: 2, - without_data: 1 - }) + expect(points_stats).to eq( + { + total: 3, + geocoded: 2, + without_data: 1 + } + ) end end end @@ -83,11 +91,13 @@ RSpec.describe StatsQuery do end it 'returns correct statistics' do - expect(points_stats).to eq({ - total: 5, - geocoded: 5, - without_data: 0 - }) + expect(points_stats).to eq( + { + total: 5, + geocoded: 5, + without_data: 0 + } + ) end end @@ -101,11 +111,13 @@ RSpec.describe StatsQuery do end it 'returns correct statistics' do - expect(points_stats).to eq({ - total: 3, - geocoded: 3, - without_data: 3 - }) + expect(points_stats).to eq( + { + total: 3, + geocoded: 3, + without_data: 3 + } + ) end end @@ -119,12 +131,55 @@ RSpec.describe StatsQuery do end it 'returns correct statistics' do - expect(points_stats).to eq({ - total: 4, - geocoded: 0, - without_data: 0 - }) + expect(points_stats).to eq( + { + total: 4, + geocoded: 0, + without_data: 0 + } + ) + end + end + + describe 'caching behavior' do + let!(:points) do + create_list(:point, 2, + user: user, + import: import, + reverse_geocoded_at: Time.current, + geodata: { 'address' => 'Test Address' }) + end + + it 'caches the geocoded stats' do + expect(Rails.cache).to receive(:fetch).with( + "dawarich/user_#{user.id}_points_geocoded_stats", + expires_in: 1.day + ).and_call_original + + points_stats + end + + it 'returns cached results on subsequent calls' do + # First call - should hit database and cache + expect(Point.connection).to receive(:select_one).once.and_call_original + first_result = points_stats + + # Second call - should use cache, not hit database + expect(Point.connection).not_to receive(:select_one) + second_result = points_stats + + expect(first_result).to eq(second_result) + end + + it 'uses counter cache for total count' do + # Ensure counter cache is set correctly + user.reload + expect(user.points_count).to eq(2) + + # The total should come from counter cache, not from SQL + result = points_stats + expect(result[:total]).to eq(user.points_count) end end end -end \ No newline at end of file +end diff --git a/spec/requests/api/v1/areas_spec.rb b/spec/requests/api/v1/areas_spec.rb index 7be57513..c5f15948 100644 --- a/spec/requests/api/v1/areas_spec.rb +++ b/spec/requests/api/v1/areas_spec.rb @@ -49,7 +49,7 @@ RSpec.describe '/api/v1/areas', type: :request do post api_v1_areas_url, headers: { 'Authorization' => "Bearer #{user.api_key}" }, params: { area: invalid_attributes } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -85,7 +85,7 @@ RSpec.describe '/api/v1/areas', type: :request do patch api_v1_area_url(area), headers: { 'Authorization' => "Bearer #{user.api_key}" }, params: { area: invalid_attributes } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end diff --git a/spec/requests/api/v1/locations_spec.rb b/spec/requests/api/v1/locations_spec.rb new file mode 100644 index 00000000..23ab3719 --- /dev/null +++ b/spec/requests/api/v1/locations_spec.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::LocationsController, type: :request do + let(:user) { create(:user) } + let(:api_key) { user.api_key } + let(:headers) { { 'Authorization' => "Bearer #{api_key}" } } + + describe 'GET /api/v1/locations' do + context 'with valid authentication' do + context 'when coordinates are provided' do + let(:latitude) { 52.5200 } + let(:longitude) { 13.4050 } + let(:mock_search_result) do + { + query: nil, + locations: [ + { + place_name: 'Kaufland Mitte', + coordinates: [52.5200, 13.4050], + address: 'Alexanderplatz 1, Berlin', + total_visits: 2, + first_visit: '2024-01-15T09:30:00Z', + last_visit: '2024-03-20T18:45:00Z', + visits: [ + { + timestamp: 1711814700, + date: '2024-03-20T18:45:00Z', + coordinates: [52.5201, 13.4051], + distance_meters: 45.5, + duration_estimate: '~25m', + points_count: 8 + } + ] + } + ], + total_locations: 1, + search_metadata: { + geocoding_provider: 'photon', + candidates_found: 3, + search_time_ms: 234 + } + } + end + + before do + allow_any_instance_of(LocationSearch::PointFinder) + .to receive(:call).and_return(mock_search_result) + end + + it 'returns successful response with search results' do + get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['query']).to be_nil + expect(json_response['locations']).to be_an(Array) + expect(json_response['locations'].first['place_name']).to eq('Kaufland Mitte') + expect(json_response['total_locations']).to eq(1) + end + + it 'includes search metadata in response' do + get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers + + json_response = JSON.parse(response.body) + expect(json_response['search_metadata']).to include( + 'geocoding_provider' => 'photon', + 'candidates_found' => 3 + ) + end + + it 'passes search parameters to PointFinder service' do + expect(LocationSearch::PointFinder) + .to receive(:new) + .with(user, hash_including( + latitude: latitude, + longitude: longitude, + limit: 50, + date_from: nil, + date_to: nil, + radius_override: nil + )) + .and_return(double(call: mock_search_result)) + + get '/api/v1/locations', params: { lat: latitude, lon: longitude }, headers: headers + end + + context 'with additional search parameters' do + let(:params) do + { + lat: latitude, + lon: longitude, + limit: 20, + date_from: '2024-01-01', + date_to: '2024-03-31', + radius_override: 200 + } + end + + it 'passes all parameters to the service' do + expect(LocationSearch::PointFinder) + .to receive(:new) + .with(user, hash_including( + latitude: latitude, + longitude: longitude, + limit: 20, + date_from: Date.parse('2024-01-01'), + date_to: Date.parse('2024-03-31'), + radius_override: 200 + )) + .and_return(double(call: mock_search_result)) + + get '/api/v1/locations', params: params, headers: headers + end + end + + context 'with invalid date parameters' do + it 'handles invalid date_from gracefully' do + expect { + get '/api/v1/locations', params: { lat: latitude, lon: longitude, date_from: 'invalid-date' }, headers: headers + }.not_to raise_error + + expect(response).to have_http_status(:ok) + end + + it 'handles invalid date_to gracefully' do + expect { + get '/api/v1/locations', params: { lat: latitude, lon: longitude, date_to: 'invalid-date' }, headers: headers + }.not_to raise_error + + expect(response).to have_http_status(:ok) + end + end + end + + context 'when no search results are found' do + let(:empty_result) do + { + query: 'NonexistentPlace', + locations: [], + total_locations: 0, + search_metadata: { geocoding_provider: nil, candidates_found: 0, search_time_ms: 0 } + } + end + + before do + allow_any_instance_of(LocationSearch::PointFinder) + .to receive(:call).and_return(empty_result) + end + + it 'returns empty results successfully' do + get '/api/v1/locations', params: { lat: 0.0, lon: 0.0 }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['locations']).to be_empty + expect(json_response['total_locations']).to eq(0) + end + end + + context 'when coordinates are missing' do + it 'returns bad request error' do + get '/api/v1/locations', headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Coordinates (lat, lon) are required') + end + end + + context 'when only latitude is provided' do + it 'returns bad request error' do + get '/api/v1/locations', params: { lat: 52.5200 }, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Coordinates (lat, lon) are required') + end + end + + context 'when coordinates are invalid' do + it 'returns bad request error for invalid latitude' do + get '/api/v1/locations', params: { lat: 91, lon: 0 }, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180') + end + + it 'returns bad request error for invalid longitude' do + get '/api/v1/locations', params: { lat: 0, lon: 181 }, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180') + end + end + + context 'when service raises an error' do + before do + allow_any_instance_of(LocationSearch::PointFinder) + .to receive(:call).and_raise(StandardError.new('Service error')) + end + + it 'returns internal server error' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: headers + + expect(response).to have_http_status(:internal_server_error) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Search failed. Please try again.') + end + end + end + + context 'without authentication' do + it 'returns unauthorized error' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 } + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with invalid API key' do + let(:invalid_headers) { { 'Authorization' => 'Bearer invalid_key' } } + + it 'returns unauthorized error' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: invalid_headers + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with user data isolation' do + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:user1_headers) { { 'Authorization' => "Bearer #{user1.api_key}" } } + + before do + # Create points for both users + create(:point, user: user1, latitude: 52.5200, longitude: 13.4050) + create(:point, user: user2, latitude: 52.5200, longitude: 13.4050) + + # Mock service to verify user isolation + allow(LocationSearch::PointFinder).to receive(:new) do |user, _params| + expect(user).to eq(user1) # Should only be called with user1 + double(call: { query: nil, locations: [], total_locations: 0, search_metadata: {} }) + end + end + + it 'only searches within the authenticated user data' do + get '/api/v1/locations', params: { lat: 52.5200, lon: 13.4050 }, headers: user1_headers + + expect(response).to have_http_status(:ok) + end + end + end + + describe 'GET /api/v1/locations/suggestions' do + context 'with valid authentication' do + let(:mock_suggestions) do + [ + { + lat: 52.5200, + lon: 13.4050, + name: 'Kaufland Mitte', + address: 'Alexanderplatz 1, Berlin', + type: 'shop' + }, + { + lat: 52.5100, + lon: 13.4000, + name: 'Kaufland Friedrichshain', + address: 'Warschauer Str. 80, Berlin', + type: 'shop' + } + ] + end + + before do + allow_any_instance_of(LocationSearch::GeocodingService) + .to receive(:search).and_return(mock_suggestions) + end + + context 'with valid search query' do + it 'returns formatted suggestions' do + get '/api/v1/locations/suggestions', params: { q: 'Kaufland' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_an(Array) + expect(json_response['suggestions'].length).to eq(2) + + first_suggestion = json_response['suggestions'].first + expect(first_suggestion).to include( + 'name' => 'Kaufland Mitte', + 'address' => 'Alexanderplatz 1, Berlin', + 'coordinates' => [52.5200, 13.4050], + 'type' => 'shop' + ) + end + + it 'limits suggestions to 10 results' do + large_suggestions = Array.new(10) do |i| + { + lat: 52.5000 + i * 0.001, + lon: 13.4000 + i * 0.001, + name: "Location #{i}", + address: "Address #{i}", + type: 'place' + } + end + + allow_any_instance_of(LocationSearch::GeocodingService) + .to receive(:search).and_return(large_suggestions) + + get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers + + json_response = JSON.parse(response.body) + expect(json_response['suggestions'].length).to eq(10) + end + end + + context 'with short search query' do + it 'returns empty suggestions for queries shorter than 2 characters' do + get '/api/v1/locations/suggestions', params: { q: 'a' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_empty + end + end + + context 'with blank query' do + it 'returns empty suggestions' do + get '/api/v1/locations/suggestions', params: { q: '' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_empty + end + end + + context 'when geocoding service raises an error' do + before do + allow_any_instance_of(LocationSearch::GeocodingService) + .to receive(:search).and_raise(StandardError.new('Geocoding error')) + end + + it 'returns empty suggestions gracefully' do + get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers + + expect(response).to have_http_status(:ok) + + json_response = JSON.parse(response.body) + expect(json_response['suggestions']).to be_empty + end + end + end + + context 'without authentication' do + it 'returns unauthorized error' do + get '/api/v1/locations/suggestions', params: { q: 'test' } + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/v1/maps/hexagons_spec.rb b/spec/requests/api/v1/maps/hexagons_spec.rb new file mode 100644 index 00000000..c0bb87a4 --- /dev/null +++ b/spec/requests/api/v1/maps/hexagons_spec.rb @@ -0,0 +1,413 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do + let(:user) { create(:user) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'GET /api/v1/maps/hexagons' do + let(:valid_params) do + { + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8, + start_date: '2024-06-01T00:00:00Z', + end_date: '2024-06-30T23:59:59Z' + } + end + + context 'with valid API key authentication' do + let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } + + before do + # Create test points within the date range and bounding box + 10.times do |i| + create(:point, + user:, + latitude: 40.7 + (i * 0.001), # Slightly different coordinates + longitude: -74.0 + (i * 0.001), + timestamp: Time.new(2024, 6, 15, 12, i).to_i) # Different times + end + end + + it 'returns hexagon data successfully' do + get '/api/v1/maps/hexagons', params: valid_params, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to have_key('type') + expect(json_response['type']).to eq('FeatureCollection') + expect(json_response).to have_key('features') + expect(json_response['features']).to be_an(Array) + end + + context 'with no data points' do + let(:empty_user) { create(:user) } + let(:empty_headers) { { 'Authorization' => "Bearer #{empty_user.api_key}" } } + + it 'returns empty feature collection' do + get '/api/v1/maps/hexagons', params: valid_params, headers: empty_headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['type']).to eq('FeatureCollection') + expect(json_response['features']).to be_empty + end + end + + context 'with edge case coordinates' do + it 'handles coordinates at dateline' do + dateline_params = valid_params.merge( + min_lon: 179.0, max_lon: -179.0, + min_lat: -1.0, max_lat: 1.0 + ) + + get '/api/v1/maps/hexagons', params: dateline_params, headers: headers + + # Should either succeed or return appropriate error, not crash + expect([200, 400, 500]).to include(response.status) + end + + it 'handles polar coordinates' do + polar_params = valid_params.merge( + min_lon: -180.0, max_lon: 180.0, + min_lat: 85.0, max_lat: 90.0 + ) + + get '/api/v1/maps/hexagons', params: polar_params, headers: headers + + # Should either succeed or return appropriate error, not crash + expect([200, 400, 500]).to include(response.status) + end + end + end + + context 'with public sharing UUID' do + let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } + let(:uuid_params) { valid_params.merge(uuid: stat.sharing_uuid) } + + before do + # Create test points within the stat's month + 15.times do |i| + create(:point, + user:, + latitude: 40.7 + (i * 0.002), + longitude: -74.0 + (i * 0.002), + timestamp: Time.new(2024, 6, 20, 10, i).to_i) + end + end + + it 'returns hexagon data without API key' do + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to have_key('type') + expect(json_response['type']).to eq('FeatureCollection') + expect(json_response).to have_key('features') + end + + it 'uses stat date range automatically' do + # Points outside the stat's month should not be included + 5.times do |i| + create(:point, + user:, + latitude: 40.7 + (i * 0.003), + longitude: -74.0 + (i * 0.003), + timestamp: Time.new(2024, 7, 1, 8, i).to_i) # July points + end + + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:success) + end + + context 'with invalid sharing UUID' do + it 'returns not found' do + invalid_uuid_params = valid_params.merge(uuid: 'invalid-uuid') + + get '/api/v1/maps/hexagons', params: invalid_uuid_params + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + + context 'with expired sharing' do + let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) } + + it 'returns not found' do + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + + context 'with disabled sharing' do + let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) } + + it 'returns not found' do + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + + context 'with pre-calculated hexagon centers' do + let(:pre_calculated_centers) do + [ + ['8a1fb46622dffff', 5, 1_717_200_000, 1_717_203_600], # h3_index, count, earliest, latest timestamps + ['8a1fb46622e7fff', 3, 1_717_210_000, 1_717_213_600], + ['8a1fb46632dffff', 8, 1_717_220_000, 1_717_223_600] + ] + end + let(:stat) do + create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6, h3_hex_ids: pre_calculated_centers) + end + + it 'uses pre-calculated hexagon centers instead of on-the-fly calculation' do + get '/api/v1/maps/hexagons', params: uuid_params + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['type']).to eq('FeatureCollection') + expect(json_response['features'].length).to eq(3) + expect(json_response['metadata']['pre_calculated']).to be true + expect(json_response['metadata']['count']).to eq(3) + + # Verify hexagon properties are generated correctly + feature = json_response['features'].first + expect(feature['type']).to eq('Feature') + expect(feature['geometry']['type']).to eq('Polygon') + expect(feature['geometry']['coordinates'].first).to be_an(Array) + expect(feature['geometry']['coordinates'].first.length).to eq(7) # 6 vertices + closing vertex + + # Verify properties include timestamp data + expect(feature['properties']['earliest_point']).to be_present + expect(feature['properties']['latest_point']).to be_present + end + + it 'generates proper hexagon polygons from centers' do + get '/api/v1/maps/hexagons', params: uuid_params + + json_response = JSON.parse(response.body) + feature = json_response['features'].first + coordinates = feature['geometry']['coordinates'].first + + # Verify hexagon has 6 unique vertices plus closing vertex + expect(coordinates.length).to eq(7) + expect(coordinates.first).to eq(coordinates.last) # Closed polygon + expect(coordinates.uniq.length).to eq(6) # 6 unique vertices + + # Verify all vertices are different (not collapsed to a point) + coordinates[0..5].each_with_index do |vertex, i| + next_vertex = coordinates[(i + 1) % 6] + expect(vertex).not_to eq(next_vertex) + end + end + end + + context 'with legacy area_too_large hexagon data' do + let(:stat) do + create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6, + h3_hex_ids: { 'area_too_large' => true }) + end + + before do + # Create points so that the service can potentially succeed + 5.times do |i| + create(:point, + user:, + latitude: 40.7 + (i * 0.001), + longitude: -74.0 + (i * 0.001), + timestamp: Time.new(2024, 6, 15, 12, i).to_i) + end + end + + it 'handles legacy area_too_large flag gracefully' do + get '/api/v1/maps/hexagons', params: uuid_params + + # The endpoint should handle the legacy data gracefully and not crash + # We're primarily testing that the condition `@stat&.h3_hex_ids&.dig('area_too_large')` is covered + expect([200, 400, 500]).to include(response.status) + end + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get '/api/v1/maps/hexagons', params: valid_params + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'with invalid API key' do + let(:headers) { { 'Authorization' => 'Bearer invalid-key' } } + + it 'returns unauthorized' do + get '/api/v1/maps/hexagons', params: valid_params, headers: headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /api/v1/maps/hexagons/bounds' do + context 'with valid API key authentication' do + let(:headers) { { 'Authorization' => "Bearer #{user.api_key}" } } + let(:date_params) do + { + start_date: Time.new(2024, 6, 1).to_i, + end_date: Time.new(2024, 6, 30, 23, 59, 59).to_i + } + end + + before do + # Create test points within the date range + create(:point, user:, latitude: 40.6, longitude: -74.1, timestamp: Time.new(2024, 6, 1, 12, 0).to_i) + create(:point, user:, latitude: 40.8, longitude: -73.9, timestamp: Time.new(2024, 6, 30, 15, 0).to_i) + create(:point, user:, latitude: 40.7, longitude: -74.0, timestamp: Time.new(2024, 6, 15, 10, 0).to_i) + end + + it 'returns bounding box for user data' do + get '/api/v1/maps/hexagons/bounds', params: date_params, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') + expect(json_response['min_lat']).to eq(40.6) + expect(json_response['max_lat']).to eq(40.8) + expect(json_response['min_lng']).to eq(-74.1) + expect(json_response['max_lng']).to eq(-73.9) + expect(json_response['point_count']).to eq(3) + end + + it 'returns not found when no points exist in date range' do + get '/api/v1/maps/hexagons/bounds', + params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' }, + headers: headers + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('No data found for the specified date range') + expect(json_response['point_count']).to eq(0) + end + + it 'requires date range parameters' do + get '/api/v1/maps/hexagons/bounds', headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('No date range specified') + end + + it 'handles different timestamp formats' do + string_date_params = { + start_date: '2024-06-01T00:00:00Z', + end_date: '2024-06-30T23:59:59Z' + } + + get '/api/v1/maps/hexagons/bounds', params: string_date_params, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') + end + + it 'handles numeric string timestamp format' do + numeric_string_params = { + start_date: '1717200000', # June 1, 2024 in timestamp + end_date: '1719791999' # June 30, 2024 in timestamp + } + + get '/api/v1/maps/hexagons/bounds', params: numeric_string_params, headers: headers + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') + end + + context 'error handling' do + it 'handles invalid date format gracefully' do + invalid_date_params = { + start_date: 'invalid-date', + end_date: '2024-06-30T23:59:59Z' + } + + get '/api/v1/maps/hexagons/bounds', params: invalid_date_params, headers: headers + + expect(response).to have_http_status(:bad_request) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to include('Invalid date format') + end + end + end + + context 'with public sharing UUID' do + let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } + + before do + # Create test points within the stat's month + create(:point, user:, latitude: 41.0, longitude: -74.5, timestamp: Time.new(2024, 6, 5, 9, 0).to_i) + create(:point, user:, latitude: 41.2, longitude: -74.2, timestamp: Time.new(2024, 6, 25, 14, 0).to_i) + end + + it 'returns bounds for the shared stat period' do + get '/api/v1/maps/hexagons/bounds', params: { uuid: stat.sharing_uuid } + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count') + expect(json_response['min_lat']).to eq(41.0) + expect(json_response['max_lat']).to eq(41.2) + expect(json_response['point_count']).to eq(2) + end + + context 'with invalid sharing UUID' do + it 'returns not found' do + get '/api/v1/maps/hexagons/bounds', params: { uuid: 'invalid-uuid' } + + expect(response).to have_http_status(:not_found) + + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Shared stats not found or no longer available') + end + end + end + + context 'without authentication' do + it 'returns unauthorized' do + get '/api/v1/maps/hexagons/bounds', + params: { start_date: '2024-06-01T00:00:00Z', end_date: '2024-06-30T23:59:59Z' } + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/v1/settings_spec.rb b/spec/requests/api/v1/settings_spec.rb index 3f6673e5..470563b7 100644 --- a/spec/requests/api/v1/settings_spec.rb +++ b/spec/requests/api/v1/settings_spec.rb @@ -47,7 +47,7 @@ RSpec.describe 'Api::V1::Settings', type: :request do it 'returns http unprocessable entity' do patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 'invalid' } } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end it 'returns an error message' do diff --git a/spec/requests/api/v1/subscriptions_spec.rb b/spec/requests/api/v1/subscriptions_spec.rb index 85e657e4..a034843e 100644 --- a/spec/requests/api/v1/subscriptions_spec.rb +++ b/spec/requests/api/v1/subscriptions_spec.rb @@ -96,13 +96,13 @@ RSpec.describe 'Api::V1::Subscriptions', type: :request do JWT.encode({ user_id: 'invalid', status: nil }, jwt_secret, 'HS256') end - it 'returns unprocessable_entity error with invalid data message' do + it 'returns unprocessable_content error with invalid data message' do allow(Subscription::DecodeJwtToken).to receive(:new).with(token) .and_raise(ArgumentError.new('Invalid token data')) post '/api/v1/subscriptions/callback', params: { token: token } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) expect(JSON.parse(response.body)['message']).to eq('Invalid subscription data received.') end end diff --git a/spec/requests/api/v1/visits_spec.rb b/spec/requests/api/v1/visits_spec.rb index a4b3a877..d5e9317a 100644 --- a/spec/requests/api/v1/visits_spec.rb +++ b/spec/requests/api/v1/visits_spec.rb @@ -64,6 +64,104 @@ RSpec.describe 'Api::V1::Visits', type: :request do end end + describe 'POST /api/v1/visits' do + let(:valid_create_params) do + { + visit: { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + context 'with valid parameters' do + let(:existing_place) { create(:place, latitude: 52.52, longitude: 13.405) } + + it 'creates a new visit' do + expect do + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + end.to change { user.visits.count }.by(1) + + expect(response).to have_http_status(:ok) + end + + it 'creates a visit with correct attributes' do + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + + json_response = JSON.parse(response.body) + expect(json_response['name']).to eq('Test Visit') + expect(json_response['status']).to eq('confirmed') + expect(json_response['duration']).to eq(120) # 2 hours in minutes + expect(json_response['place']['latitude']).to eq(52.52) + expect(json_response['place']['longitude']).to eq(13.405) + end + + it 'creates a place for the visit' do + expect do + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + end.to change { Place.count }.by(1) + + created_place = Place.last + expect(created_place.name).to eq('Test Visit') + expect(created_place.latitude).to eq(52.52) + expect(created_place.longitude).to eq(13.405) + expect(created_place.source).to eq('manual') + end + + it 'reuses existing place when coordinates are exactly the same' do + create(:visit, user: user, place: existing_place) + + expect do + post '/api/v1/visits', params: valid_create_params, headers: auth_headers + end.not_to(change { Place.count }) + + json_response = JSON.parse(response.body) + expect(json_response['place']['id']).to eq(existing_place.id) + end + end + + context 'with invalid parameters' do + context 'when required fields are missing' do + let(:missing_name_params) do + valid_create_params.deep_merge(visit: { name: '' }) + end + + it 'returns unprocessable entity status' do + post '/api/v1/visits', params: missing_name_params, headers: auth_headers + + expect(response).to have_http_status(:unprocessable_content) + end + + it 'returns error message' do + post '/api/v1/visits', params: missing_name_params, headers: auth_headers + + json_response = JSON.parse(response.body) + + expect(json_response['error']).to eq('Failed to create visit') + end + + it 'does not create a visit' do + expect do + post '/api/v1/visits', params: missing_name_params, headers: auth_headers + end.not_to(change { Visit.count }) + end + end + end + + context 'with invalid API key' do + let(:invalid_auth_headers) { { 'Authorization' => 'Bearer invalid-key' } } + + it 'returns unauthorized status' do + post '/api/v1/visits', params: valid_create_params, headers: invalid_auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end + describe 'PUT /api/v1/visits/:id' do let(:visit) { create(:visit, user:) } @@ -101,7 +199,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do it 'renders a JSON response with errors for the visit' do put "/api/v1/visits/#{visit.id}", params: invalid_attributes, headers: auth_headers - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -136,7 +234,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do it 'returns an error when fewer than 2 visits are specified' do post '/api/v1/visits/merge', params: { visit_ids: [visit1.id] }, headers: auth_headers - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) json_response = JSON.parse(response.body) expect(json_response['error']).to include('At least 2 visits must be selected') end @@ -166,7 +264,7 @@ RSpec.describe 'Api::V1::Visits', type: :request do post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, visit2.id] }, headers: auth_headers - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) json_response = JSON.parse(response.body) expect(json_response['error']).to include('Failed to merge visits') end @@ -218,10 +316,67 @@ RSpec.describe 'Api::V1::Visits', type: :request do post '/api/v1/visits/bulk_update', params: invalid_update_params, headers: auth_headers - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) json_response = JSON.parse(response.body) expect(json_response['error']).to include('Invalid status') end end end + + describe 'DELETE /api/v1/visits/:id' do + let!(:visit) { create(:visit, user: user, place: place) } + let!(:other_user_visit) { create(:visit, user: other_user, place: place) } + + context 'when visit exists and belongs to current user' do + it 'deletes the visit' do + expect do + delete "/api/v1/visits/#{visit.id}", headers: auth_headers + end.to change { user.visits.count }.by(-1) + + expect(response).to have_http_status(:no_content) + end + + it 'removes the visit from the database' do + delete "/api/v1/visits/#{visit.id}", headers: auth_headers + + expect { visit.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when visit does not exist' do + it 'returns not found status' do + delete '/api/v1/visits/999999', headers: auth_headers + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Visit not found') + end + end + + context 'when visit belongs to another user' do + it 'returns not found status' do + delete "/api/v1/visits/#{other_user_visit.id}", headers: auth_headers + + expect(response).to have_http_status(:not_found) + json_response = JSON.parse(response.body) + expect(json_response['error']).to eq('Visit not found') + end + + it 'does not delete the visit' do + expect do + delete "/api/v1/visits/#{other_user_visit.id}", headers: auth_headers + end.not_to(change { Visit.count }) + end + end + + context 'with invalid API key' do + let(:invalid_auth_headers) { { 'Authorization' => 'Bearer invalid-key' } } + + it 'returns unauthorized status' do + delete "/api/v1/visits/#{visit.id}", headers: invalid_auth_headers + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb index eab3f9a0..621a86cc 100644 --- a/spec/requests/authentication_spec.rb +++ b/spec/requests/authentication_spec.rb @@ -6,9 +6,9 @@ RSpec.describe 'Authentication', type: :request do let(:user) { create(:user, password: 'password123') } before do - stub_request(:get, "https://api.github.com/repos/Freika/dawarich/tags") - .with(headers: { 'Accept'=>'*/*', 'Accept-Encoding'=>/.*/, - 'Host'=>'api.github.com', 'User-Agent'=>/.*/}) + stub_request(:get, 'https://api.github.com/repos/Freika/dawarich/tags') + .with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => /.*/, + 'Host' => 'api.github.com', 'User-Agent' => /.*/ }) .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) end @@ -66,4 +66,104 @@ RSpec.describe 'Authentication', type: :request do expect(response).to redirect_to(new_user_session_path) end end + + describe 'Mobile iOS Authentication' do + it 'redirects to iOS success path when signing in with iOS client header' do + # Make a login request with the iOS client header (user NOT pre-signed in) + post user_session_path, params: { + user: { email: user.email, password: 'password123' } + }, headers: { + 'X-Dawarich-Client' => 'ios', + 'Accept' => 'text/html' + } + + # Should redirect to iOS success endpoint after successful login + # The redirect will include a token parameter generated by after_sign_in_path_for + expect(response).to redirect_to(%r{auth/ios/success\?token=}) + expect(response.location).to include('token=') + end + + it 'stores iOS client header in session' do + # Test that the header gets stored when accessing sign-in page + get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' } + + expect(session[:dawarich_client]).to eq('ios') + end + + it 'redirects to iOS success path using stored session value' do + # Simulate iOS app accessing sign-in page first (stores header in session) + get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' } + + # Then sign-in POST request without header (relies on session) + post user_session_path, params: { + user: { email: user.email, password: 'password123' } + }, headers: { + 'Accept' => 'text/html' + } + + # Should still redirect to iOS success endpoint using session value + expect(response).to redirect_to(%r{auth/ios/success\?token=}) + expect(response.location).to include('token=') + end + + it 'returns plain text response for iOS success endpoint with token' do + # Generate a test JWT token using the same service as the controller + payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i } + test_token = Subscription::EncodeJwtToken.new( + payload, ENV['AUTH_JWT_SECRET_KEY'] + ).call + + get ios_success_path, params: { token: test_token } + + expect(response).to be_successful + expect(response.content_type).to include('text/plain') + expect(response.body).to eq('Authentication successful! You can close this window.') + end + + it 'returns JSON response when no token is provided to iOS success endpoint' do + get ios_success_path + + expect(response).to be_successful + expect(response.content_type).to include('application/json') + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be true + expect(json_response['message']).to eq('iOS authentication successful') + expect(json_response['redirect_url']).to eq(root_url) + end + + it 'generates JWT token with correct payload for iOS authentication' do + # Test JWT token generation directly using the same logic as after_sign_in_path_for + payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i } + + # Create JWT token using the same service + token = Subscription::EncodeJwtToken.new( + payload, ENV['AUTH_JWT_SECRET_KEY'] + ).call + + expect(token).to be_present + + # Decode the token to verify the payload + decoded_payload = JWT.decode( + token, + ENV['AUTH_JWT_SECRET_KEY'], + true, + { algorithm: 'HS256' } + ).first + + expect(decoded_payload['api_key']).to eq(user.api_key) + expect(decoded_payload['exp']).to be_present + end + + it 'uses default path for non-iOS clients' do + # Make a login request without iOS client header (user NOT pre-signed in) + post user_session_path, params: { + user: { email: user.email, password: 'password123' } + } + + # Should redirect to default path (not iOS success) + expect(response).not_to redirect_to(%r{auth/ios/success}) + expect(response.location).not_to include('auth/ios/success') + end + end end diff --git a/spec/requests/exports_spec.rb b/spec/requests/exports_spec.rb index 89658348..8fd9f43c 100644 --- a/spec/requests/exports_spec.rb +++ b/spec/requests/exports_spec.rb @@ -70,7 +70,7 @@ RSpec.describe '/exports', type: :request do it 'renders a response with 422 status (i.e. to display the "new" template)' do post(exports_url, params:) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end diff --git a/spec/requests/families_spec.rb b/spec/requests/families_spec.rb new file mode 100644 index 00000000..5bc4e826 --- /dev/null +++ b/spec/requests/families_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family', type: :request do + let(:user) { create(:user) } + let(:other_user) { create(:user) } + let(:family) { create(:family, creator: user) } + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + sign_in user + end + + describe 'GET /family' do + it 'shows the family page' do + get "/family" + expect(response).to have_http_status(:ok) + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to new family path' do + get "/family" + expect(response).to redirect_to(new_family_path) + end + end + end + + describe 'GET /family/new' do + context 'when user is not in a family' do + let(:user_without_family) { create(:user) } + + before { sign_in user_without_family } + + it 'renders the new family form' do + get '/family/new' + expect(response).to have_http_status(:ok) + end + end + + context 'when user is already in a family' do + it 'redirects to family show page' do + get '/family/new' + expect(response).to redirect_to(family_path) + end + end + end + + describe 'POST /family' do + let(:user_without_family) { create(:user) } + + before { sign_in user_without_family } + + context 'with valid attributes' do + let(:valid_attributes) { { family: { name: 'Test Family' } } } + + it 'creates a new family' do + expect do + post '/family', params: valid_attributes + end.to change(Family, :count).by(1) + end + + it 'creates a family membership for the user' do + expect do + post '/family', params: valid_attributes + end.to change(Family::Membership, :count).by(1) + end + + it 'redirects to the new family with success message' do + post '/family', params: valid_attributes + + expect(response).to have_http_status(:found) + expect(response.location).to eq family_url + follow_redirect! + expect(response.body).to include('Family created successfully!') + end + end + + context 'with invalid attributes' do + let(:invalid_attributes) { { family: { name: '' } } } + + it 'does not create a family' do + expect do + post '/family', params: invalid_attributes + end.not_to change(Family, :count) + end + + it 'renders the new template with errors' do + post '/family', params: invalid_attributes + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe 'GET /family/edit' do + it 'shows the edit form' do + get "/family/edit" + expect(response).to have_http_status(:ok) + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + get "/family/edit" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + end + + describe 'PATCH /family' do + let(:new_attributes) { { family: { name: 'Updated Family Name' } } } + + context 'with valid attributes' do + it 'updates the family' do + patch "/family", params: new_attributes + family.reload + expect(family.name).to eq('Updated Family Name') + expect(response).to redirect_to(family_path) + end + end + + context 'with invalid attributes' do + let(:invalid_attributes) { { family: { name: '' } } } + + it 'does not update the family' do + original_name = family.name + patch "/family", params: invalid_attributes + family.reload + expect(family.name).to eq(original_name) + expect(response).to have_http_status(:unprocessable_content) + end + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + patch "/family", params: new_attributes + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + end + + describe 'DELETE /family' do + context 'when family has only one member' do + it 'deletes the family' do + expect { delete '/family' }.to change(Family, :count).by(-1) + expect(response).to redirect_to(new_family_path) + end + end + + context 'when family has multiple members' do + before do + create(:family_membership, user: other_user, family: family, role: :member) + end + + it 'does not delete the family' do + expect { delete "/family" }.not_to change(Family, :count) + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Cannot delete family with members') + end + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + delete "/family" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + end + + + describe 'authorization for outsiders' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'denies access to show when user is not in family' do + get "/family" + expect(response).to redirect_to(new_family_path) + end + + it 'redirects to family page when user is not in family for edit' do + get "/family/edit" + expect(response).to redirect_to(new_family_path) + end + + it 'redirects to family page when user is not in family for update' do + patch "/family", params: { family: { name: 'Hacked' } } + expect(response).to redirect_to(new_family_path) + end + + it 'redirects to family page when user is not in family for destroy' do + delete "/family" + expect(response).to redirect_to(new_family_path) + end + + end + + describe 'authentication required' do + before { sign_out user } + + it 'redirects to login for index' do + get '/family' + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for show' do + get "/family" + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for new' do + get '/family/new' + + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for create' do + post '/family', params: { family: { name: 'Test' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for edit' do + get "/family/edit" + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for update' do + patch "/family", params: { family: { name: 'Test' } } + expect(response).to redirect_to(new_user_session_path) + end + + it 'redirects to login for destroy' do + delete "/family" + expect(response).to redirect_to(new_user_session_path) + end + end +end diff --git a/spec/requests/family/invitations_spec.rb b/spec/requests/family/invitations_spec.rb new file mode 100644 index 00000000..b75d501e --- /dev/null +++ b/spec/requests/family/invitations_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family::Invitations', type: :request do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + let(:invitation) { create(:family_invitation, family: family, invited_by: user) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'GET /family/invitations' do + before { sign_in user } + + it 'shows pending invitations' do + invitation # create the invitation + get "/family/invitations" + expect(response).to have_http_status(:ok) + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + get "/family/invitations" + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + get "/family/invitations" + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'GET /invitations/:token (public invitation view)' do + context 'when invitation is valid and pending' do + it 'shows the invitation without authentication' do + get "/invitations/#{invitation.token}" + expect(response).to have_http_status(:ok) + end + end + + context 'when invitation is expired' do + before { invitation.update!(expires_at: 1.day.ago) } + + it 'redirects with error message' do + get "/invitations/#{invitation.token}" + expect(response).to redirect_to(root_path) + follow_redirect! + expect(response.body).to include('This invitation has expired') + end + end + + context 'when invitation is not pending' do + before { invitation.update!(status: :accepted) } + + it 'redirects with error message' do + get "/invitations/#{invitation.token}" + expect(response).to redirect_to(root_path) + follow_redirect! + expect(response.body).to include('This invitation is no longer valid') + end + end + + context 'when invitation does not exist' do + it 'returns not found' do + get '/invitations/invalid-token' + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /family/invitations' do + before { sign_in user } + + context 'with valid email' do + let(:valid_params) do + { family_invitation: { email: 'newuser@example.com' } } + end + + it 'creates a new invitation' do + expect do + post "/family/invitations", params: valid_params + end.to change(Family::Invitation, :count).by(1) + end + + it 'redirects with success message' do + post "/family/invitations", params: valid_params + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Invitation sent successfully!') + end + end + + context 'with duplicate email' do + let(:duplicate_params) do + { family_invitation: { email: invitation.email } } + end + + it 'does not create a duplicate invitation' do + invitation # create the existing invitation + expect do + post "/family/invitations", params: duplicate_params + end.not_to change(Family::Invitation, :count) + end + + it 'redirects with error message' do + invitation # create the existing invitation + post "/family/invitations", params: duplicate_params + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Invitation already sent to this email') + end + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + post "/family/invitations", params: { + family_invitation: { email: 'test@example.com' } + } + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + post "/family/invitations", params: { + family_invitation: { email: 'test@example.com' } + } + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + post "/family/invitations", params: { + family_invitation: { email: 'test@example.com' } + } + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'DELETE /family/invitations/:id' do + before { sign_in user } + + it 'cancels the invitation' do + delete "/family/invitations/#{invitation.token}" + invitation.reload + expect(invitation.status).to eq('cancelled') + end + + it 'redirects with success message' do + delete "/family/invitations/#{invitation.token}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Invitation cancelled') + end + + context 'when user is not the owner' do + before { membership.update!(role: :member) } + + it 'redirects due to authorization failure' do + delete "/family/invitations/#{invitation.token}" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + delete "/family/invitations/#{invitation.token}" + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + delete "/family/invitations/#{invitation.token}" + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'invitation workflow integration' do + let(:invitee) { create(:user) } + + it 'completes full invitation acceptance workflow' do + # 1. Owner creates invitation + sign_in user + post "/family/invitations", params: { + family_invitation: { email: invitee.email } + } + expect(response).to redirect_to(family_path) + + created_invitation = Family::Invitation.last + expect(created_invitation.email).to eq(invitee.email) + + # 2. Invitee views public invitation page + sign_out user + get "/invitations/#{created_invitation.token}" + expect(response).to have_http_status(:ok) + + # 3. Invitee accepts invitation + sign_in invitee + post accept_family_invitation_path(token: created_invitation.token) + expect(response).to redirect_to(family_path) + + # 4. Verify invitee is now in family + expect(invitee.reload.family).to eq(family) + expect(created_invitation.reload.status).to eq('accepted') + end + end +end diff --git a/spec/requests/family/memberships_spec.rb b/spec/requests/family/memberships_spec.rb new file mode 100644 index 00000000..5efde1ba --- /dev/null +++ b/spec/requests/family/memberships_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family::Memberships', type: :request do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) } + let(:member_user) { create(:user) } + let!(:member_membership) { create(:family_membership, user: member_user, family: family, role: :member) } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + sign_in user + end + + describe 'POST /family/memberships' do + let(:invitee) { create(:user) } + let(:invitee_invitation) { create(:family_invitation, family: family, invited_by: user, email: invitee.email) } + + context 'with valid invitation and user' do + before { sign_in invitee } + + it 'accepts the invitation' do + expect do + post accept_family_invitation_path(token: invitee_invitation.token) + end.to change { invitee.reload.family }.from(nil).to(family) + end + + it 'redirects with success message' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Welcome to the family!') + end + + it 'marks invitation as accepted' do + post accept_family_invitation_path(token: invitee_invitation.token) + invitee_invitation.reload + expect(invitee_invitation.status).to eq('accepted') + end + end + + context 'when user is already in a family' do + let(:other_family) { create(:family) } + + before do + create(:family_membership, user: invitee, family: other_family, role: :member) + sign_in invitee + end + + it 'does not accept the invitation' do + expect do + post accept_family_invitation_path(token: invitee_invitation.token) + end.not_to(change { invitee.reload.family }) + end + + it 'redirects with error message' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('You must leave your current family before joining a new one') + end + end + + context 'when invitation is expired' do + before do + invitee_invitation.update!(expires_at: 1.day.ago) + sign_in invitee + end + + it 'does not accept the invitation' do + expect do + post accept_family_invitation_path(token: invitee_invitation.token) + end.not_to(change { invitee.reload.family }) + end + + it 'redirects with error message' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('This invitation is no longer valid or has expired') + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + post accept_family_invitation_path(token: invitee_invitation.token) + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'DELETE /family/members/:id' do + context 'when removing a regular member' do + it 'removes the member from the family' do + expect do + delete "/family/members/#{member_membership.id}" + end.to change(Family::Membership, :count).by(-1) + end + + it 'redirects with success message' do + member_email = member_user.email + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include("#{member_email} has been removed from the family") + end + + it 'removes the user from the family' do + delete "/family/members/#{member_membership.id}" + expect(member_user.reload.family).to be_nil + end + end + + context 'when trying to remove the owner' do + it 'does not remove the owner' do + expect do + delete "/family/members/#{owner_membership.id}" + end.not_to change(Family::Membership, :count) + end + + it 'redirects with error message explaining owners must delete family' do + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Family owners cannot remove their own membership. To leave the family, delete it instead.') + end + + it 'prevents owner removal even when they are the only member' do + member_membership.destroy! + + expect do + delete "/family/members/#{owner_membership.id}" + end.not_to change(Family::Membership, :count) + + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Family owners cannot remove their own membership') + end + end + + context 'when membership does not belong to the family' do + let(:other_family) { create(:family) } + let(:other_membership) { create(:family_membership, family: other_family) } + + it 'returns not found' do + delete "/family/members/#{other_membership.id}" + expect(response).to have_http_status(:not_found) + end + end + + context 'when user is not in the family' do + let(:outsider) { create(:user) } + + before { sign_in outsider } + + it 'redirects to families index' do + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(new_family_path) + end + end + + context 'when not authenticated' do + before { sign_out user } + + it 'redirects to login' do + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(new_user_session_path) + end + end + end + + describe 'authorization for different member roles' do + context 'when member tries to remove another member' do + before { sign_in member_user } + + it 'returns forbidden' do + delete "/family/members/#{owner_membership.id}" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + end + end + + end + + describe 'member removal workflow' do + it 'removes member and updates family associations' do + # Verify initial state + expect(family.members).to include(user, member_user) + expect(member_user.family).to eq(family) + + # Remove member + delete "/family/members/#{member_membership.id}" + + # Verify removal + expect(response).to redirect_to(family_path) + expect(family.reload.members).to include(user) + expect(family.members).not_to include(member_user) + expect(member_user.reload.family).to be_nil + end + + it 'prevents removing owner regardless of member count' do + # Verify initial state + expect(family.members.count).to eq(2) + expect(user.family_owner?).to be true + + # Try to remove owner + delete "/family/members/#{owner_membership.id}" + + # Verify prevention + expect(response).to redirect_to(family_path) + expect(family.reload.members).to include(user, member_user) + expect(user.reload.family).to eq(family) + end + + it 'prevents removing owner even when they are the only member' do + # Remove other member first + member_membership.destroy! + + # Verify only owner remains + expect(family.reload.members.count).to eq(1) + expect(family.members).to include(user) + + # Try to remove owner - should be prevented + expect do + delete "/family/members/#{owner_membership.id}" + end.not_to change(Family::Membership, :count) + + expect(response).to redirect_to(family_path) + expect(user.reload.family).to eq(family) + expect(family.reload).to be_present + end + + it 'requires owners to use family deletion to leave the family' do + member_membership.destroy! + + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + expect(flash[:alert]).to include('Family owners cannot remove their own membership') + + delete "/family" + expect(response).to redirect_to(new_family_path) + expect(user.reload.family).to be_nil + end + end +end diff --git a/spec/requests/family_workflows_spec.rb b/spec/requests/family_workflows_spec.rb new file mode 100644 index 00000000..38f64ed9 --- /dev/null +++ b/spec/requests/family_workflows_spec.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Family Workflows', type: :request do + let(:user1) { create(:user, email: 'alice@example.com') } + let(:user2) { create(:user, email: 'bob@example.com') } + let(:user3) { create(:user, email: 'charlie@example.com') } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'Complete family creation and management workflow' do + it 'allows creating a family, inviting members, and managing the family' do + # Step 1: User1 creates a family + sign_in user1 + + get '/family/new' + expect(response).to have_http_status(:ok) + + post '/family', params: { family: { name: 'The Smith Family' } } + + # The redirect should be to the newly created family + expect(response).to have_http_status(:found) + family = Family.find_by(name: 'The Smith Family') + expect(family).to be_present + expect(family.name).to eq('The Smith Family') + expect(family.creator).to eq(user1) + expect(user1.reload.family).to eq(family) + expect(user1.family_owner?).to be true + + # Step 2: User1 invites User2 + post "/family/invitations", params: { + family_invitation: { email: user2.email } + } + expect(response).to redirect_to(family_path) + + invitation = family.family_invitations.find_by(email: user2.email) + expect(invitation).to be_present + expect(invitation.email).to eq(user2.email) + expect(invitation.family).to eq(family) + expect(invitation.pending?).to be true + + # Step 3: User2 views and accepts invitation + sign_out user1 + + # Public invitation view (no auth required) + get "/invitations/#{invitation.token}" + expect(response).to have_http_status(:ok) + + # User2 accepts invitation + sign_in user2 + post accept_family_invitation_path(token: invitation.token) + expect(response).to redirect_to(family_path) + + expect(user2.reload.family).to eq(family) + expect(user2.family_owner?).to be false + expect(invitation.reload.accepted?).to be true + + # Step 4: User1 invites User3 + sign_in user1 + post "/family/invitations", params: { + family_invitation: { email: user3.email } + } + + invitation2 = family.family_invitations.find_by(email: user3.email) + expect(invitation2).to be_present + expect(invitation2.email).to eq(user3.email) + + # Step 5: User3 accepts invitation + sign_in user3 + post accept_family_invitation_path(token: invitation2.token) + + expect(user3.reload.family).to eq(family) + expect(family.reload.members.count).to eq(3) + + # Step 6: Family owner views members on family show page + sign_in user1 + get "/family" + expect(response).to have_http_status(:ok) + + # Step 7: Owner removes a member + delete "/family/members/#{user2.family_membership.id}" + expect(response).to redirect_to(family_path) + + expect(user2.reload.family).to be_nil + expect(family.reload.members.count).to eq(2) + expect(family.members).to include(user1, user3) + expect(family.members).not_to include(user2) + end + end + + describe 'Family invitation expiration workflow' do + let(:family) { create(:family, name: 'Test Family', creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + let!(:invitation) do + create(:family_invitation, family: family, email: user2.email, invited_by: user1, expires_at: 1.day.ago) + end + + it 'handles expired invitations correctly' do + # User2 tries to view expired invitation + get "/invitations/#{invitation.token}" + expect(response).to redirect_to(root_path) + follow_redirect! + expect(response.body).to include('This invitation has expired') + + # User2 tries to accept expired invitation + sign_in user2 + post accept_family_invitation_path(token: invitation.token) + expect(response).to redirect_to(root_path) + + expect(user2.reload.family).to be_nil + expect(invitation.reload.pending?).to be true + end + end + + describe 'Multiple family membership prevention workflow' do + let(:family1) { create(:family, name: 'Family 1', creator: user1) } + let(:family2) { create(:family, name: 'Family 2', creator: user2) } + let!(:user1_membership) { create(:family_membership, user: user1, family: family1, role: :owner) } + let!(:user2_membership) { create(:family_membership, user: user2, family: family2, role: :owner) } + let!(:invitation1) { create(:family_invitation, family: family1, email: user3.email, invited_by: user1) } + let!(:invitation2) { create(:family_invitation, family: family2, email: user3.email, invited_by: user2) } + + it 'prevents users from joining multiple families' do + # User3 accepts invitation to Family 1 + sign_in user3 + post accept_family_invitation_path(token: invitation1.token) + expect(response).to redirect_to(family_path) + expect(user3.family).to eq(family1) + + # User3 tries to accept invitation to Family 2 + post accept_family_invitation_path(token: invitation2.token) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('You must leave your current family') + + expect(user3.reload.family).to eq(family1) # Still in first family + end + end + + describe 'Family ownership transfer and leaving workflow' do + let(:family) { create(:family, creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) } + + it 'prevents owner from leaving when members exist' do + sign_in user1 + + # Owner tries to leave family with members (using memberships destroy route) + owner_membership = user1.family_membership + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('cannot remove their own membership') + + expect(user1.reload.family).to eq(family) + expect(user1.family_owner?).to be true + end + + it 'allows owner to leave when they are the only member' do + sign_in user1 + + # Remove the member first + delete "/family/members/#{member_membership.id}" + + # Owner cannot leave even when alone - they must delete the family instead + owner_membership = user1.reload.family_membership + delete "/family/members/#{owner_membership.id}" + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('cannot remove their own membership') + + expect(user1.reload.family).to eq(family) + end + + it 'allows members to leave freely' do + sign_in user2 + + delete "/family/members/#{member_membership.id}" + expect(response).to redirect_to(new_family_path) + + expect(user2.reload.family).to be_nil + expect(family.reload.members.count).to eq(1) + expect(family.members).to include(user1) + expect(family.members).not_to include(user2) + end + end + + describe 'Family deletion workflow' do + let(:family) { create(:family, creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + + context 'when members exist' do + let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) } + + it 'prevents family deletion when members exist' do + sign_in user1 + + expect do + delete "/family" + end.not_to change(Family, :count) + + expect(response).to redirect_to(family_path) + follow_redirect! + expect(response.body).to include('Cannot delete family with members') + end + end + + it 'allows family deletion when owner is the only member' do + sign_in user1 + + expect do + delete "/family" + end.to change(Family, :count).by(-1) + + expect(response).to redirect_to(new_family_path) + expect(user1.reload.family).to be_nil + end + end + + describe 'Authorization workflow' do + let(:family) { create(:family, creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: user2, family: family, role: :member) } + + it 'enforces proper authorization for family management' do + # Member cannot invite others + sign_in user2 + post "/family/invitations", params: { + family_invitation: { email: user3.email } + } + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Member cannot remove other members + delete "/family/members/#{owner_membership.id}" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Member cannot edit family + patch "/family", params: { family: { name: 'Hacked Family' } } + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Member cannot delete family + delete "/family" + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') + + # Outsider cannot access family + sign_in user3 + get "/family" + expect(response).to redirect_to(new_family_path) + end + end + + describe 'Email invitation workflow' do + let(:family) { create(:family, name: 'Test Family', creator: user1) } + let!(:owner_membership) { create(:family_membership, user: user1, family: family, role: :owner) } + + it 'handles invitation emails correctly' do + sign_in user1 + + # Mock email delivery + expect do + post "/family/invitations", params: { + family_invitation: { email: 'newuser@example.com' } + } + end.to change(Family::Invitation, :count).by(1) + + invitation = family.family_invitations.find_by(email: 'newuser@example.com') + expect(invitation.email).to eq('newuser@example.com') + expect(invitation.token).to be_present + expect(invitation.expires_at).to be > Time.current + end + end + + describe 'Navigation and redirect workflow' do + it 'handles proper redirects for family-related navigation' do + # User without family can access new family page + sign_in user1 + get '/family/new' + expect(response).to have_http_status(:ok) + + # User creates family + post '/family', params: { family: { name: 'Test Family' } } + expect(response).to have_http_status(:found) + + # User with family can view their family + get '/family' + expect(response).to have_http_status(:ok) + + # User with family gets redirected from new family page + get '/family/new' + expect(response).to redirect_to(family_path) + end + end +end diff --git a/spec/requests/imports_spec.rb b/spec/requests/imports_spec.rb index 56eb3333..09481269 100644 --- a/spec/requests/imports_spec.rb +++ b/spec/requests/imports_spec.rb @@ -62,9 +62,10 @@ RSpec.describe 'Imports', type: :request do end it 'prevents viewing other users import' do - expect { - get import_path(other_import) - }.to raise_error(Pundit::NotAuthorizedError) + get import_path(other_import) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end @@ -98,9 +99,10 @@ RSpec.describe 'Imports', type: :request do end it 'prevents access to new import form' do - expect { - get new_import_path - }.to raise_error(Pundit::NotAuthorizedError) + get new_import_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end diff --git a/spec/requests/settings/background_jobs_spec.rb b/spec/requests/settings/background_jobs_spec.rb index 64b415af..f2bea2cd 100644 --- a/spec/requests/settings/background_jobs_spec.rb +++ b/spec/requests/settings/background_jobs_spec.rb @@ -18,7 +18,7 @@ RSpec.describe '/settings/background_jobs', type: :request do get settings_background_jobs_url expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end @@ -32,7 +32,7 @@ RSpec.describe '/settings/background_jobs', type: :request do get settings_background_jobs_url expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end context 'when job name is start_immich_import' do @@ -104,7 +104,7 @@ RSpec.describe '/settings/background_jobs', type: :request do get settings_background_jobs_url expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end @@ -118,7 +118,7 @@ RSpec.describe '/settings/background_jobs', type: :request do get settings_background_jobs_url expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end context 'when user is an admin' do @@ -128,7 +128,7 @@ RSpec.describe '/settings/background_jobs', type: :request do get settings_background_jobs_url expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end end @@ -138,7 +138,7 @@ RSpec.describe '/settings/background_jobs', type: :request do post settings_background_jobs_url, params: { job_name: 'start_reverse_geocoding' } expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end context 'when job name is start_immich_import' do @@ -146,7 +146,7 @@ RSpec.describe '/settings/background_jobs', type: :request do post settings_background_jobs_url, params: { job_name: 'start_immich_import' } expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end @@ -155,7 +155,7 @@ RSpec.describe '/settings/background_jobs', type: :request do post settings_background_jobs_url, params: { job_name: 'start_photoprism_import' } expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end @@ -166,7 +166,7 @@ RSpec.describe '/settings/background_jobs', type: :request do get settings_background_jobs_url expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end end diff --git a/spec/requests/settings/users_spec.rb b/spec/requests/settings/users_spec.rb index 51079587..b8bc5a38 100644 --- a/spec/requests/settings/users_spec.rb +++ b/spec/requests/settings/users_spec.rb @@ -15,7 +15,7 @@ RSpec.describe '/settings/users', type: :request do it 'redirects to sign in page' do post settings_users_url, params: { user: valid_attributes } - expect(response).to redirect_to(root_url) + expect(response).to redirect_to(new_user_session_path) end end @@ -64,7 +64,7 @@ RSpec.describe '/settings/users', type: :request do it 'renders a response with 422 status (i.e. to display the "new" template)' do post settings_users_url, params: { user: invalid_attributes } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -101,7 +101,7 @@ RSpec.describe '/settings/users', type: :request do get settings_users_url expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end @@ -110,7 +110,7 @@ RSpec.describe '/settings/users', type: :request do post settings_users_url, params: { user: valid_attributes } expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end @@ -121,7 +121,7 @@ RSpec.describe '/settings/users', type: :request do patch settings_user_url(user), params: { user: valid_attributes } expect(response).to redirect_to(root_url) - expect(flash[:notice]).to eq('You are not authorized to perform this action.') + expect(flash[:alert]).to eq('You are not authorized to perform this action.') end end end diff --git a/spec/requests/shared/stats_spec.rb b/spec/requests/shared/stats_spec.rb new file mode 100644 index 00000000..49bf7ebb --- /dev/null +++ b/spec/requests/shared/stats_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Shared::Stats', type: :request do + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + context 'public sharing' do + let(:user) { create(:user) } + let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } + + describe 'GET /shared/month/:uuid' do + context 'with valid sharing UUID' do + before do + # Create some test points for data bounds calculation + create_list(:point, 5, user:, timestamp: Time.new(2024, 6, 15).to_i) + end + + it 'renders the public month view' do + get shared_stat_url(stat.sharing_uuid) + + expect(response).to have_http_status(:success) + expect(response.body).to include('Monthly Digest') + expect(response.body).to include('June 2024') + end + + it 'includes required content in response' do + get shared_stat_url(stat.sharing_uuid) + + expect(response.body).to include('June 2024') + expect(response.body).to include('Monthly Digest') + expect(response.body).to include('data-public-stat-map-uuid-value') + expect(response.body).to include(stat.sharing_uuid) + end + end + + context 'with invalid sharing UUID' do + it 'redirects to root with alert' do + get shared_stat_url('invalid-uuid') + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared stats not found or no longer available') + end + end + + context 'with expired sharing' do + let(:stat) { create(:stat, :with_sharing_expired, user:, year: 2024, month: 6) } + + it 'redirects to root with alert' do + get shared_stat_url(stat.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared stats not found or no longer available') + end + end + + context 'with disabled sharing' do + let(:stat) { create(:stat, :with_sharing_disabled, user:, year: 2024, month: 6) } + + it 'redirects to root with alert' do + get shared_stat_url(stat.sharing_uuid) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to eq('Shared stats not found or no longer available') + end + end + + context 'when stat has no points' do + it 'renders successfully' do + get shared_stat_url(stat.sharing_uuid) + + expect(response).to have_http_status(:success) + expect(response.body).to include('Monthly Digest') + end + end + end + + describe 'PATCH /stats/:year/:month/sharing' do + context 'when user is signed in' do + let!(:stat_to_share) { create(:stat, user:, year: 2024, month: 6) } + + before { sign_in user } + + context 'enabling sharing' do + it 'enables sharing and returns success' do + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(true) + expect(json_response['sharing_url']).to be_present + expect(json_response['message']).to eq('Sharing enabled successfully') + + stat_to_share.reload + expect(stat_to_share.sharing_enabled?).to be(true) + expect(stat_to_share.sharing_uuid).to be_present + end + + it 'sets custom expiration when provided' do + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1', expiration: '1_week' }, + as: :json + + expect(response).to have_http_status(:success) + stat_to_share.reload + expect(stat_to_share.sharing_enabled?).to be(true) + end + end + + context 'disabling sharing' do + let!(:enabled_stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 7) } + + it 'disables sharing and returns success' do + patch sharing_stats_path(year: 2024, month: 7), + params: { enabled: '0' }, + as: :json + + expect(response).to have_http_status(:success) + + json_response = JSON.parse(response.body) + expect(json_response['success']).to be(true) + expect(json_response['message']).to eq('Sharing disabled successfully') + + enabled_stat.reload + expect(enabled_stat.sharing_enabled?).to be(false) + end + end + + context 'when stat does not exist' do + it 'returns not found' do + patch sharing_stats_path(year: 2024, month: 12), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:not_found) + end + end + end + + context 'when user is not signed in' do + it 'returns unauthorized' do + patch sharing_stats_path(year: 2024, month: 6), + params: { enabled: '1' }, + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end + end +end diff --git a/spec/requests/trips_spec.rb b/spec/requests/trips_spec.rb index af654048..53549905 100644 --- a/spec/requests/trips_spec.rb +++ b/spec/requests/trips_spec.rb @@ -114,7 +114,7 @@ RSpec.describe '/trips', type: :request do it "renders a response with 422 status (i.e. to display the 'new' template)" do post trips_url, params: { trip: invalid_attributes } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -151,7 +151,7 @@ RSpec.describe '/trips', type: :request do it 'renders a response with 422 status' do patch trip_url(trip), params: { trip: invalid_attributes } - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end diff --git a/spec/requests/users/registrations_spec.rb b/spec/requests/users/registrations_spec.rb new file mode 100644 index 00000000..add2d1aa --- /dev/null +++ b/spec/requests/users/registrations_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Users::Registrations', type: :request do + let(:family_owner) { create(:user) } + let(:family) { create(:family, creator: family_owner) } + let!(:owner_membership) { create(:family_membership, user: family_owner, family: family, role: :owner) } + let(:invitation) { create(:family_invitation, family: family, invited_by: family_owner, email: 'invited@example.com') } + + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + + describe 'Family Invitation Registration Flow' do + context 'when accessing registration with a valid invitation token' do + it 'shows family-focused registration page' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Join #{family.name}!") + expect(response.body).to include(family_owner.email) + expect(response.body).to include(invitation.email) + expect(response.body).to include('Create Account & Join Family') + end + + it 'pre-fills email field with invitation email' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include('value="invited@example.com"') + end + + it 'makes email field readonly' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include('readonly') + end + + it 'hides normal login links' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).not_to include('devise/shared/links') + end + end + + context 'when accessing registration without invitation token' do + it 'shows normal registration page' do + get new_user_registration_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Register now!') + expect(response.body).to include('take control over your location data') + expect(response.body).not_to include('Join') + expect(response.body).to include('Sign up') + end + end + + context 'when creating account with valid invitation token' do + let(:user_params) do + { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + } + end + + let(:request_params) do + { + user: user_params, + invitation_token: invitation.token + } + end + + it 'creates user and accepts invitation automatically' do + expect do + post user_registration_path, params: request_params + end.to change(User, :count).by(1) + .and change { invitation.reload.status }.from('pending').to('accepted') + + new_user = User.find_by(email: invitation.email) + expect(new_user).to be_present + expect(new_user.family).to eq(family) + expect(family.reload.members).to include(new_user) + end + + it 'redirects to family page after successful registration' do + post user_registration_path, params: request_params + + expect(response).to redirect_to(family_path) + end + + it 'displays success message with family name' do + post user_registration_path, params: request_params + + # Check that user got the default registration success message + # (family welcome message is set but may be overridden by Devise) + expect(flash[:notice]).to include("signed up successfully") + end + end + + context 'when creating account with invalid invitation token' do + it 'creates user but does not accept any invitation' do + expect do + post user_registration_path, params: { + user: { + email: 'user@example.com', + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: 'invalid-token' + } + end.to change(User, :count).by(1) + + new_user = User.find_by(email: 'user@example.com') + expect(new_user.family).to be_nil + end + end + + context 'when invitation email does not match registration email' do + it 'creates user but does not accept invitation' do + expect do + post user_registration_path, params: { + user: { + email: 'different@example.com', + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + new_user = User.find_by(email: 'different@example.com') + expect(new_user.family).to be_nil + expect(invitation.reload.status).to eq('pending') + end + end + end + + describe 'Self-Hosted Mode' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true') + end + + context 'when accessing registration without invitation token' do + it 'redirects to root with error message' do + get new_user_registration_path + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + + it 'prevents account creation' do + expect do + post user_registration_path, params: { + user: { + email: 'test@example.com', + password: 'password123', + password_confirmation: 'password123' + } + } + end.not_to change(User, :count) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + end + + context 'when accessing registration with valid invitation token' do + it 'allows registration page access' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Join #{family.name}!") + end + + it 'allows account creation' do + expect do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + expect(response).to redirect_to(family_path) + end + end + + context 'when accessing registration with expired invitation' do + before { invitation.update!(expires_at: 1.day.ago) } + + it 'redirects to root with error message' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + end + + context 'when accessing registration with cancelled invitation' do + before { invitation.update!(status: :cancelled) } + + it 'redirects to root with error message' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') + end + end + end + + describe 'Non-Self-Hosted Mode' do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('false') + end + + context 'when accessing registration without invitation token' do + it 'allows normal registration' do + get new_user_registration_path + + expect(response).to have_http_status(:ok) + expect(response.body).to include('Register now!') + end + + it 'allows account creation' do + expect do + post user_registration_path, params: { + user: { + email: 'test@example.com', + password: 'password123', + password_confirmation: 'password123' + } + } + end.to change(User, :count).by(1) + + expect(response).to redirect_to(root_path) + end + end + end + + describe 'Invitation Token Handling' do + it 'accepts invitation token from params' do + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include("Join #{invitation.family.name}!") + end + + it 'accepts invitation token from nested user params' do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + + new_user = User.find_by(email: invitation.email) + expect(new_user.family).to eq(family) + end + + it 'handles session-stored invitation token' do + # Simulate session storage by passing the token directly in params + # (In real usage, this would come from the session after redirect from invitation page) + get new_user_registration_path(invitation_token: invitation.token) + + expect(response.body).to include("Join #{invitation.family.name}!") + end + end + + describe 'Error Handling' do + context 'when invitation acceptance fails' do + before do + # Mock service failure + allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_return(false) + allow_any_instance_of(Families::AcceptInvitation).to receive(:error_message).and_return('Mock error') + end + + it 'creates user but shows invitation error in flash' do + expect do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + expect(flash[:alert]).to include('Mock error') + end + end + + context 'when invitation acceptance raises exception' do + before do + # Mock service exception + allow_any_instance_of(Families::AcceptInvitation).to receive(:call).and_raise(StandardError, 'Test error') + end + + it 'creates user but shows generic error in flash' do + expect do + post user_registration_path, params: { + user: { + email: invitation.email, + password: 'password123', + password_confirmation: 'password123' + }, + invitation_token: invitation.token + } + end.to change(User, :count).by(1) + + expect(flash[:alert]).to include('there was an issue accepting the invitation') + end + end + end +end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb index 8c0bcdf5..219c4d4d 100644 --- a/spec/requests/users_spec.rb +++ b/spec/requests/users_spec.rb @@ -11,19 +11,21 @@ RSpec.describe 'Users', type: :request do describe 'GET /users/sign_up' do context 'when self-hosted' do before do - stub_const('SELF_HOSTED', true) + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true') end - it 'returns http success' do + it 'redirects to root path' do get '/users/sign_up' - expect(response).to have_http_status(:not_found) + expect(response).to redirect_to(root_path) + expect(flash[:alert]).to include('Registration is not available') end end context 'when not self-hosted' do before do - stub_const('SELF_HOSTED', false) - Rails.application.reload_routes! + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return(nil) end it 'returns http success' do diff --git a/spec/serializers/api/point_serializer_spec.rb b/spec/serializers/api/point_serializer_spec.rb index 8e7b51e5..d897ed92 100644 --- a/spec/serializers/api/point_serializer_spec.rb +++ b/spec/serializers/api/point_serializer_spec.rb @@ -7,14 +7,25 @@ RSpec.describe Api::PointSerializer do subject(:serializer) { described_class.new(point).call } let(:point) { create(:point) } - let(:expected_json) { point.attributes.except(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) } + let(:all_excluded) { Api::PointSerializer::EXCLUDED_ATTRIBUTES } + let(:expected_json) do + point.attributes.except(*all_excluded).tap do |attributes| + attributes['latitude'] = point.lat.to_s + attributes['longitude'] = point.lon.to_s + end + end it 'returns JSON with correct attributes' do expect(serializer.to_json).to eq(expected_json.to_json) end it 'does not include excluded attributes' do - expect(serializer).not_to include(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) + expect(serializer).not_to include(*all_excluded) + end + + it 'extracts coordinates from PostGIS geometry' do + expect(serializer['latitude']).to eq(point.lat.to_s) + expect(serializer['longitude']).to eq(point.lon.to_s) end end end diff --git a/spec/serializers/api/user_serializer_spec.rb b/spec/serializers/api/user_serializer_spec.rb index 178c64e0..d215f1e4 100644 --- a/spec/serializers/api/user_serializer_spec.rb +++ b/spec/serializers/api/user_serializer_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Api::UserSerializer do describe '#call' do subject(:serializer) { described_class.new(user).call } - let(:user) { create(:user, email: 'test@example.com', theme: 'dark') } + let(:user) { create(:user) } it 'returns JSON with correct user attributes' do expect(serializer[:user][:email]).to eq(user.email) @@ -81,5 +81,61 @@ RSpec.describe Api::UserSerializer do expect(settings[:maps]).to eq({ 'distance_unit' => 'mi' }) end end + + context 'subscription data' do + context 'when not self-hosted (hosted instance)' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + end + + it 'includes subscription data' do + expect(serializer).to have_key(:subscription) + expect(serializer[:subscription]).to include(:status, :active_until) + end + + it 'returns correct subscription values' do + subscription = serializer[:subscription] + expect(subscription[:status]).to eq(user.status) + expect(subscription[:active_until]).to eq(user.active_until) + end + + context 'with specific subscription values' do + it 'serializes trial user status correctly' do + # When not self-hosted, users start with trial status via start_trial callback + test_user = create(:user) + serializer_result = described_class.new(test_user).call + subscription = serializer_result[:subscription] + + expect(subscription[:status]).to eq('trial') + expect(subscription[:active_until]).to be_within(1.second).of(7.days.from_now) + end + + it 'serializes subscription data with all expected fields' do + test_user = create(:user) + serializer_result = described_class.new(test_user).call + subscription = serializer_result[:subscription] + + expect(subscription).to include(:status, :active_until) + expect(subscription[:status]).to be_a(String) + expect(subscription[:active_until]).to be_a(ActiveSupport::TimeWithZone) + end + end + end + + context 'when self-hosted' do + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(true) + end + + it 'does not include subscription data' do + expect(serializer).not_to have_key(:subscription) + end + + it 'still includes user and settings data' do + expect(serializer).to have_key(:user) + expect(serializer[:user]).to include(:email, :theme, :settings) + end + end + end end end diff --git a/spec/serializers/track_serializer_spec.rb b/spec/serializers/track_serializer_spec.rb index 6622b23d..cfda4e5c 100644 --- a/spec/serializers/track_serializer_spec.rb +++ b/spec/serializers/track_serializer_spec.rb @@ -123,7 +123,7 @@ RSpec.describe TrackSerializer do context 'with very large values' do let(:track) do create(:track, user: user, - distance: 1_000_000.0, + distance: 999_999.99, avg_speed: 999.99, duration: 86_400, # 24 hours in seconds elevation_gain: 10_000, @@ -133,7 +133,7 @@ RSpec.describe TrackSerializer do end it 'handles large values correctly' do - expect(serialized_track[:distance]).to eq(1_000_000) + expect(serialized_track[:distance]).to eq(999_999) expect(serialized_track[:avg_speed]).to eq(999.99) expect(serialized_track[:duration]).to eq(86_400) expect(serialized_track[:elevation_gain]).to eq(10_000) diff --git a/spec/services/areas/visits/create_spec.rb b/spec/services/areas/visits/create_spec.rb index 18865d6a..f66064ab 100644 --- a/spec/services/areas/visits/create_spec.rb +++ b/spec/services/areas/visits/create_spec.rb @@ -4,7 +4,7 @@ require 'rails_helper' RSpec.describe Areas::Visits::Create do describe '#call' do - let(:user) { create(:user) } + let!(:user) { create(:user) } let(:home_area) { create(:area, user:, latitude: 0, longitude: 0, radius: 100) } let(:work_area) { create(:area, user:, latitude: 1, longitude: 1, radius: 100) } diff --git a/spec/services/cache/clean_spec.rb b/spec/services/cache/clean_spec.rb new file mode 100644 index 00000000..1d0ee55c --- /dev/null +++ b/spec/services/cache/clean_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Cache::Clean do + before { Rails.cache.clear } + + describe '.call' do + let!(:user1) { create(:user) } + let!(:user2) { create(:user) } + let(:user_1_years_tracked_key) { "dawarich/user_#{user1.id}_years_tracked" } + let(:user_2_years_tracked_key) { "dawarich/user_#{user2.id}_years_tracked" } + let(:user_1_points_geocoded_stats_key) { "dawarich/user_#{user1.id}_points_geocoded_stats" } + let(:user_2_points_geocoded_stats_key) { "dawarich/user_#{user2.id}_points_geocoded_stats" } + let(:user_1_countries_key) { "dawarich/user_#{user1.id}_countries" } + let(:user_2_countries_key) { "dawarich/user_#{user2.id}_countries" } + let(:user_1_cities_key) { "dawarich/user_#{user1.id}_cities" } + let(:user_2_cities_key) { "dawarich/user_#{user2.id}_cities" } + + before do + # Set up cache entries that should be cleaned + Rails.cache.write('cache_jobs_scheduled', true) + Rails.cache.write(CheckAppVersion::VERSION_CACHE_KEY, '1.0.0') + Rails.cache.write(user_1_years_tracked_key, { 2023 => %w[Jan Feb] }) + Rails.cache.write(user_2_years_tracked_key, { 2023 => %w[Mar Apr] }) + Rails.cache.write(user_1_points_geocoded_stats_key, { geocoded: 5, without_data: 2 }) + Rails.cache.write(user_2_points_geocoded_stats_key, { geocoded: 3, without_data: 1 }) + end + + it 'deletes control flag cache' do + expect(Rails.cache.exist?('cache_jobs_scheduled')).to be true + + described_class.call + + expect(Rails.cache.exist?('cache_jobs_scheduled')).to be false + end + + it 'deletes version cache' do + expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be true + + described_class.call + + expect(Rails.cache.exist?(CheckAppVersion::VERSION_CACHE_KEY)).to be false + end + + it 'deletes years tracked cache for all users' do + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be true + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be true + + described_class.call + + expect(Rails.cache.exist?(user_1_years_tracked_key)).to be false + expect(Rails.cache.exist?(user_2_years_tracked_key)).to be false + end + + it 'deletes points geocoded stats cache for all users' do + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be true + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be true + + described_class.call + + expect(Rails.cache.exist?(user_1_points_geocoded_stats_key)).to be false + expect(Rails.cache.exist?(user_2_points_geocoded_stats_key)).to be false + end + + it 'deletes countries and cities cache for all users' do + Rails.cache.write(user_1_countries_key, %w[USA Canada]) + Rails.cache.write(user_2_countries_key, %w[France Germany]) + Rails.cache.write(user_1_cities_key, ['New York', 'Toronto']) + Rails.cache.write(user_2_cities_key, %w[Paris Berlin]) + + expect(Rails.cache.exist?(user_1_countries_key)).to be true + expect(Rails.cache.exist?(user_2_countries_key)).to be true + expect(Rails.cache.exist?(user_1_cities_key)).to be true + expect(Rails.cache.exist?(user_2_cities_key)).to be true + + described_class.call + + expect(Rails.cache.exist?(user_1_countries_key)).to be false + expect(Rails.cache.exist?(user_2_countries_key)).to be false + expect(Rails.cache.exist?(user_1_cities_key)).to be false + expect(Rails.cache.exist?(user_2_cities_key)).to be false + end + + it 'logs cache cleaning process' do + expect(Rails.logger).to receive(:info).with('Cleaning cache...') + expect(Rails.logger).to receive(:info).with('Cache cleaned') + + described_class.call + end + + it 'handles users being added during execution gracefully' do + # Create a user that will be found during the cleaning process + user3 = nil + + allow(User).to receive(:find_each).and_yield(user1).and_yield(user2) do |&block| + # Create a new user while iterating - this should not cause errors + user3 = create(:user) + Rails.cache.write("dawarich/user_#{user3.id}_years_tracked", { 2023 => ['May'] }) + Rails.cache.write("dawarich/user_#{user3.id}_points_geocoded_stats", { geocoded: 1, without_data: 0 }) + + # Continue with the original block + [user1, user2].each(&block) + end + + expect { described_class.call }.not_to raise_error + + # The new user's cache should still exist since it wasn't processed + expect(Rails.cache.exist?("dawarich/user_#{user3.id}_years_tracked")).to be true + expect(Rails.cache.exist?("dawarich/user_#{user3.id}_points_geocoded_stats")).to be true + end + end +end diff --git a/spec/services/families/accept_invitation_spec.rb b/spec/services/families/accept_invitation_spec.rb new file mode 100644 index 00000000..28dca538 --- /dev/null +++ b/spec/services/families/accept_invitation_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::AcceptInvitation do + let(:family) { create(:family) } + let(:invitee) { create(:user, email: 'invitee@example.com') } + let(:invitation) { create(:family_invitation, family: family, email: invitee.email) } + let(:service) { described_class.new(invitation: invitation, user: invitee) } + + describe '#call' do + context 'when invitation can be accepted' do + it 'creates membership for user' do + expect { service.call }.to change(Family::Membership, :count).by(1) + membership = invitee.reload.family_membership + expect(membership.family).to eq(family) + expect(membership.role).to eq('member') + end + + it 'updates invitation status to accepted' do + service.call + invitation.reload + expect(invitation.status).to eq('accepted') + end + + it 'sends notifications to both parties' do + expect { service.call }.to change(Notification, :count).by(2) + + user_notification = Notification.find_by(user: invitee, title: 'Welcome to Family!') + expect(user_notification).to be_present + + owner_notification = Notification.find_by(user: family.creator, title: 'New Family Member!') + expect(owner_notification).to be_present + end + + it 'returns true' do + expect(service.call).to be true + end + end + + context 'when user is already in another family' do + let(:other_family) { create(:family) } + let!(:existing_membership) { create(:family_membership, user: invitee, family: other_family) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + + it 'sets appropriate error message' do + service.call + expect(service.error_message).to eq('You must leave your current family before joining a new one.') + end + + it 'does not change user family' do + expect { service.call }.not_to(change { invitee.reload.family }) + end + end + + context 'when invitation is expired' do + let(:invitation) { create(:family_invitation, family: family, email: invitee.email, expires_at: 1.day.ago) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + + context 'when invitation is not pending' do + let(:invitation) { create(:family_invitation, :accepted, family: family, email: invitee.email) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + + context 'when email does not match user' do + let(:wrong_user) { create(:user, email: 'wrong@example.com') } + let(:service) { described_class.new(invitation: invitation, user: wrong_user) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + + context 'when family is at max capacity' do + before do + # Fill family to max capacity + create_list(:family_membership, Family::MAX_MEMBERS, family: family, role: :member) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + end + end +end diff --git a/spec/services/families/create_spec.rb b/spec/services/families/create_spec.rb new file mode 100644 index 00000000..216c344b --- /dev/null +++ b/spec/services/families/create_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::Create do + let(:user) { create(:user) } + let(:service) { described_class.new(user: user, name: 'Test Family') } + + describe '#call' do + context 'when user is not in a family' do + it 'creates a family successfully' do + expect { service.call }.to change(Family, :count).by(1) + expect(service.family.name).to eq('Test Family') + expect(service.family.creator).to eq(user) + end + + it 'creates owner membership' do + service.call + membership = user.reload.family_membership + expect(membership.role).to eq('owner') + expect(membership.family).to eq(service.family) + end + + it 'returns true on success' do + expect(service.call).to be true + end + end + + context 'when user is already in a family' do + before { create(:family_membership, user: user) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create a family' do + expect { service.call }.not_to change(Family, :count) + end + + it 'does not create a membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + + it 'sets appropriate error message' do + service.call + expect(service.error_message).to eq('You must leave your current family before creating a new one') + end + end + + context 'when user has already created a family before' do + before do + # User creates and then deletes their family membership, but family still exists + old_family = create(:family, creator: user) + membership = create(:family_membership, user: user, family: old_family, role: :owner) + membership.destroy! # User leaves the family but family still exists + user.reload # Ensure user association is refreshed + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create a family' do + expect { service.call }.not_to change(Family, :count) + end + + it 'does not create a membership' do + expect { service.call }.not_to change(Family::Membership, :count) + end + + it 'sets appropriate error message' do + service.call + expect(service.error_message).to eq('You have already created a family. Each user can only create one family') + end + end + end +end diff --git a/spec/services/families/invite_spec.rb b/spec/services/families/invite_spec.rb new file mode 100644 index 00000000..8ea3c747 --- /dev/null +++ b/spec/services/families/invite_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::Invite do + let(:owner) { create(:user) } + let(:family) { create(:family, creator: owner) } + let!(:owner_membership) { create(:family_membership, user: owner, family: family, role: :owner) } + let(:email) { 'invitee@example.com' } + let(:service) { described_class.new(family: family, email: email, invited_by: owner) } + + describe '#call' do + context 'when invitation is valid' do + it 'creates an invitation' do + expect { service.call }.to change(Family::Invitation, :count).by(1) + + invitation = owner.sent_family_invitations.last + + expect(invitation.family).to eq(family) + expect(invitation.email).to eq(email) + expect(invitation.invited_by).to eq(owner) + end + + it 'sends invitation email' do + expect(FamilyMailer).to receive(:invitation).and_call_original + expect_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_later) + service.call + end + + it 'sends notification to inviter' do + expect { service.call }.to change(Notification, :count).by(1) + + notification = owner.notifications.last + + expect(notification.user).to eq(owner) + expect(notification.title).to eq('Invitation Sent') + end + + it 'returns true' do + expect(service.call).to be true + end + end + + context 'when inviter is not family owner' do + let(:member) { create(:user) } + let!(:member_membership) { create(:family_membership, user: member, family: family, role: :member) } + let(:service) { described_class.new(family: family, email: email, invited_by: member) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'when family is at max capacity' do + before do + # Create max members (5 total including owner) + create_list(:family_membership, Family::MAX_MEMBERS - 1, family: family, role: :member) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'when user is already in a family' do + let(:existing_user) { create(:user, email: email) } + let(:other_family) { create(:family) } + + before do + create(:family_membership, user: existing_user, family: other_family) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'when pending invitation already exists' do + before do + create(:family_invitation, family: family, email: email, invited_by: owner) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create another invitation' do + expect { service.call }.not_to change(Family::Invitation, :count) + end + end + + context 'with invalid email' do + let(:service) { described_class.new(family: family, email: 'invalid-email', invited_by: owner) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'has validation errors' do + service.call + expect(service.errors[:email]).to be_present + end + end + end + + describe 'email normalization' do + let(:service) { described_class.new(family: family, email: ' UPPER@EXAMPLE.COM ', invited_by: owner) } + + it 'normalizes email to lowercase and strips whitespace' do + service.call + invitation = family.family_invitations.last + + expect(invitation.email).to eq('upper@example.com') + end + end + + describe 'validations' do + it 'validates presence of email' do + service = described_class.new(family: family, email: '', invited_by: owner) + expect(service).not_to be_valid + expect(service.errors[:email]).to include("can't be blank") + end + + it 'validates email format' do + service = described_class.new(family: family, email: 'invalid-email', invited_by: owner) + expect(service).not_to be_valid + expect(service.errors[:email]).to include('is invalid') + end + end +end diff --git a/spec/services/families/memberships/destroy_spec.rb b/spec/services/families/memberships/destroy_spec.rb new file mode 100644 index 00000000..ac2475e5 --- /dev/null +++ b/spec/services/families/memberships/destroy_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::Memberships::Destroy do + let(:user) { create(:user) } + let(:family) { create(:family, creator: user) } + let(:service) { described_class.new(user: user) } + + describe '#call' do + context 'when user is a member (not owner)' do + let(:member) { create(:user) } + let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: member, family: family, role: :member) } + let(:service) { described_class.new(user: member) } + + it 'removes the membership' do + result = service.call + expect(result).to be_truthy, "Expected service to succeed but got error: #{service.error_message}" + expect(Family::Membership.count).to eq(1) # Only owner should remain + expect(member.reload.family_membership).to be_nil + end + + it 'sends notification to member who left' do + expect { service.call }.to change(Notification, :count).by(2) + + member_notification = member.notifications.last + expect(member_notification.title).to eq('Left Family') + expect(member_notification.content).to include(family.name) + end + + it 'sends notification to family owner' do + service.call + + owner_notification = user.notifications.last + expect(owner_notification.title).to eq('Family Member Left') + expect(owner_notification.content).to include(member.email) + expect(owner_notification.content).to include(family.name) + end + + it 'returns true' do + expect(service.call).to be true + end + end + + context 'when user is family owner with no other members' do + let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } + + it 'prevents owner from leaving' do + expect { service.call }.not_to change(Family::Membership, :count) + expect(user.reload.family_membership).to be_present + end + + it 'does not delete the family' do + expect { service.call }.not_to change(Family, :count) + end + + it 'returns false' do + expect(service.call).to be false + end + + it 'sets error message' do + service.call + expect(service.error_message).to include('cannot remove their own membership') + end + end + + context 'when user is family owner with other members' do + let(:member) { create(:user) } + let!(:owner_membership) { create(:family_membership, user: user, family: family, role: :owner) } + let!(:member_membership) { create(:family_membership, user: member, family: family, role: :member) } + + it 'returns false' do + expect(service.call).to be false + end + + it 'does not remove membership' do + expect { service.call }.not_to change(Family::Membership, :count) + expect(user.reload.family_membership).to be_present + end + end + + context 'when user is not in a family' do + it 'returns false' do + expect(service.call).to be false + end + + it 'does not create any notifications' do + expect { service.call }.not_to change(Notification, :count) + end + end + end +end diff --git a/spec/services/families/update_location_sharing_spec.rb b/spec/services/families/update_location_sharing_spec.rb new file mode 100644 index 00000000..72243d5b --- /dev/null +++ b/spec/services/families/update_location_sharing_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Families::UpdateLocationSharing do + include ActiveSupport::Testing::TimeHelpers + + describe '.call' do + subject(:call_service) do + described_class.new(user: user, enabled: enabled, duration: duration).call + end + + let(:duration) { '1h' } + + context 'when the user is in a family' do + let(:user) { create(:user) } + let!(:family_membership) { create(:family_membership, user: user) } + + context 'when enabling location sharing with a duration' do + let(:enabled) { true } + + around do |example| + travel_to(Time.zone.local(2024, 1, 1, 12, 0, 0)) { example.run } + end + + it 'returns a successful result with the expected payload' do + result = call_service + + expect(result).to be_success + expect(result.status).to eq(:ok) + expect(result.payload[:success]).to be true + expect(result.payload[:enabled]).to be true + expect(result.payload[:duration]).to eq('1h') + expect(result.payload[:message]).to eq('Location sharing enabled for 1 hour') + expect(result.payload[:expires_at]).to eq(1.hour.from_now.iso8601) + expect(result.payload[:expires_at_formatted]).to eq(1.hour.from_now.strftime('%b %d at %I:%M %p')) + end + end + + context 'when disabling location sharing' do + let(:enabled) { false } + let(:duration) { nil } + + it 'returns a successful result without expiration details' do + result = call_service + + expect(result).to be_success + expect(result.payload[:success]).to be true + expect(result.payload[:enabled]).to be false + expect(result.payload[:message]).to eq('Location sharing disabled') + expect(result.payload).not_to have_key(:expires_at) + expect(result.payload).not_to have_key(:expires_at_formatted) + end + end + + context 'when update raises an unexpected error' do + let(:enabled) { true } + + before do + allow(user).to receive(:update_family_location_sharing!).and_raise(StandardError, 'boom') + end + + it 'returns a failure result with internal server error status' do + result = call_service + + expect(result).not_to be_success + expect(result.status).to eq(:internal_server_error) + expect(result.payload[:success]).to be false + expect(result.payload[:message]).to eq('An error occurred while updating location sharing') + end + end + end + + context 'when the user is not in a family' do + let(:user) { create(:user) } + let(:enabled) { true } + + it 'returns a failure result with unprocessable content status' do + result = call_service + + expect(result).not_to be_success + expect(result.status).to eq(:unprocessable_content) + expect(result.payload[:success]).to be false + expect(result.payload[:message]).to eq('Failed to update location sharing setting') + end + end + end +end diff --git a/spec/services/google_maps/phone_takeout_importer_spec.rb b/spec/services/google_maps/phone_takeout_importer_spec.rb index b48f9891..d35ea598 100644 --- a/spec/services/google_maps/phone_takeout_importer_spec.rb +++ b/spec/services/google_maps/phone_takeout_importer_spec.rb @@ -14,7 +14,7 @@ RSpec.describe GoogleMaps::PhoneTakeoutImporter do context 'when file content is an object' do # This file contains 3 duplicates - let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout.json') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout_w_3_duplicates.json') } let(:file) { Rack::Test::UploadedFile.new(file_path, 'application/json') } let(:import) { create(:import, user:, name: 'phone_takeout.json', file:) } @@ -39,13 +39,13 @@ RSpec.describe GoogleMaps::PhoneTakeoutImporter do it 'creates points with correct data' do parser - expect(Point.all[6].lat).to eq(27.696576) - expect(Point.all[6].lon).to eq(-97.376949) - expect(Point.all[6].timestamp).to eq(1_693_180_140) + expect(user.points[6].lat).to eq(27.696576) + expect(user.points[6].lon).to eq(-97.376949) + expect(user.points[6].timestamp).to eq(1_693_180_140) - expect(Point.last.lat).to eq(27.709617) - expect(Point.last.lon).to eq(-97.375988) - expect(Point.last.timestamp).to eq(1_693_180_320) + expect(user.points.last.lat).to eq(27.709617) + expect(user.points.last.lon).to eq(-97.375988) + expect(user.points.last.timestamp).to eq(1_693_180_320) end end end diff --git a/spec/services/gpx/track_importer_spec.rb b/spec/services/gpx/track_importer_spec.rb index 5aeb7117..341e0fc3 100644 --- a/spec/services/gpx/track_importer_spec.rb +++ b/spec/services/gpx/track_importer_spec.rb @@ -57,11 +57,13 @@ RSpec.describe Gpx::TrackImporter do it 'creates points with correct data' do parser - expect(Point.first.lat).to eq(37.1722103) - expect(Point.first.lon).to eq(-3.55468) - expect(Point.first.altitude).to eq(1066) - expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i) - expect(Point.first.velocity).to eq('2.9') + point = user.points.first + + expect(point.lat).to eq(37.1722103) + expect(point.lon).to eq(-3.55468) + expect(point.altitude).to eq(1066) + expect(point.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i) + expect(point.velocity).to eq('2.9') end end @@ -71,11 +73,13 @@ RSpec.describe Gpx::TrackImporter do it 'creates points with correct data' do parser - expect(Point.first.lat).to eq(10.758321212464024) - expect(Point.first.lon).to eq(106.64234449272531) - expect(Point.first.altitude).to eq(17) - expect(Point.first.timestamp).to eq(1_730_626_211) - expect(Point.first.velocity).to eq('2.8') + point = user.points.first + + expect(point.lat).to eq(10.758321212464024) + expect(point.lon).to eq(106.64234449272531) + expect(point.altitude).to eq(17) + expect(point.timestamp).to eq(1_730_626_211) + expect(point.velocity).to eq('2.8') end end diff --git a/spec/services/imports/create_spec.rb b/spec/services/imports/create_spec.rb index 91fc643b..1410b2ee 100644 --- a/spec/services/imports/create_spec.rb +++ b/spec/services/imports/create_spec.rb @@ -17,9 +17,25 @@ RSpec.describe Imports::Create do it 'sets status to processing at start' do service.call + expect(import.reload.status).to eq('processing').or eq('completed') end + it 'updates the import source' do + service.call + + expect(import.reload.source).to eq('owntracks') + end + + it 'resets points counter cache' do + allow(User).to receive(:reset_counters) + + service.call + + expect(User).to have_received(:reset_counters).with(user.id, :points) + end + + context 'when import succeeds' do it 'sets status to completed' do service.call @@ -29,7 +45,7 @@ RSpec.describe Imports::Create do context 'when import fails' do before do - allow(OwnTracks::Importer).to receive(:new).with(import, user.id).and_raise(StandardError) + allow(OwnTracks::Importer).to receive(:new).with(import, user.id, kind_of(String)).and_raise(StandardError) end it 'sets status to failed' do @@ -51,7 +67,7 @@ RSpec.describe Imports::Create do it 'calls the GoogleMaps::SemanticHistoryImporter' do expect(GoogleMaps::SemanticHistoryImporter).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end @@ -62,10 +78,16 @@ RSpec.describe Imports::Create do context 'when source is google_phone_takeout' do let(:import) { create(:import, source: 'google_phone_takeout') } + let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout_w_3_duplicates.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'phone-takeout_w_3_duplicates.json', + content_type: 'application/json') + end it 'calls the GoogleMaps::PhoneTakeoutImporter' do expect(GoogleMaps::PhoneTakeoutImporter).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end @@ -81,7 +103,7 @@ RSpec.describe Imports::Create do it 'calls the OwnTracks::Importer' do expect(OwnTracks::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end @@ -102,7 +124,7 @@ RSpec.describe Imports::Create do context 'when import fails' do before do - allow(OwnTracks::Importer).to receive(:new).with(import, user.id).and_raise(StandardError) + allow(OwnTracks::Importer).to receive(:new).with(import, user.id, kind_of(String)).and_raise(StandardError) end context 'when self-hosted' do @@ -153,37 +175,56 @@ RSpec.describe Imports::Create do it 'calls the Gpx::TrackImporter' do expect(Gpx::TrackImporter).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end context 'when source is geojson' do let(:import) { create(:import, source: 'geojson') } + let(:file_path) { Rails.root.join('spec/fixtures/files/geojson/export.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'export.json', + content_type: 'application/json') + end it 'calls the Geojson::Importer' do expect(Geojson::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end context 'when source is immich_api' do let(:import) { create(:import, source: 'immich_api') } + let(:file_path) { Rails.root.join('spec/fixtures/files/immich/geodata.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'geodata.json', + content_type: 'application/json') + end it 'calls the Photos::Importer' do expect(Photos::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) + service.call end end context 'when source is photoprism_api' do let(:import) { create(:import, source: 'photoprism_api') } + let(:file_path) { Rails.root.join('spec/fixtures/files/immich/geodata.json') } + + before do + import.file.attach(io: File.open(file_path), filename: 'geodata.json', + content_type: 'application/json') + end it 'calls the Photos::Importer' do expect(Photos::Importer).to \ - receive(:new).with(import, user.id).and_return(double(call: true)) + receive(:new).with(import, user.id, kind_of(String)).and_return(double(call: true)) service.call end end diff --git a/spec/services/imports/source_detector_spec.rb b/spec/services/imports/source_detector_spec.rb new file mode 100644 index 00000000..e3cba810 --- /dev/null +++ b/spec/services/imports/source_detector_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Imports::SourceDetector do + let(:detector) { described_class.new(file_content, filename) } + let(:filename) { nil } + + describe '#detect_source' do + context 'with Google Semantic History format' do + let(:file_content) { file_fixture('google/semantic_history.json').read } + + it 'detects google_semantic_history format' do + expect(detector.detect_source).to eq(:google_semantic_history) + end + end + + context 'with Google Records format' do + let(:file_content) { file_fixture('google/records.json').read } + + it 'detects google_records format' do + expect(detector.detect_source).to eq(:google_records) + end + end + + context 'with Google Phone Takeout format' do + let(:file_content) { file_fixture('google/phone-takeout_w_3_duplicates.json').read } + + it 'detects google_phone_takeout format' do + expect(detector.detect_source).to eq(:google_phone_takeout) + end + end + + context 'with Google Phone Takeout array format' do + let(:file_content) { file_fixture('google/location-history.json').read } + + it 'detects google_phone_takeout format' do + expect(detector.detect_source).to eq(:google_phone_takeout) + end + end + + context 'with GeoJSON format' do + let(:file_content) { file_fixture('geojson/export.json').read } + + it 'detects geojson format' do + expect(detector.detect_source).to eq(:geojson) + end + end + + context 'with OwnTracks REC file' do + let(:file_content) { file_fixture('owntracks/2024-03.rec').read } + let(:filename) { 'test.rec' } + + it 'detects owntracks format' do + expect(detector.detect_source).to eq(:owntracks) + end + end + + context 'with OwnTracks content without .rec extension' do + let(:file_content) { '{"_type":"location","lat":52.225,"lon":13.332}' } + let(:filename) { 'test.json' } + + it 'detects owntracks format based on content' do + expect(detector.detect_source).to eq(:owntracks) + end + end + + context 'with GPX file' do + let(:file_content) { file_fixture('gpx/gpx_track_single_segment.gpx').read } + let(:filename) { 'test.gpx' } + + it 'detects gpx format' do + expect(detector.detect_source).to eq(:gpx) + end + end + + context 'with invalid JSON' do + let(:file_content) { 'invalid json content' } + + it 'returns nil for invalid JSON' do + expect(detector.detect_source).to be_nil + end + end + + context 'with unknown JSON format' do + let(:file_content) { '{"unknown": "format", "data": []}' } + + it 'returns nil for unknown format' do + expect(detector.detect_source).to be_nil + end + end + + context 'with empty content' do + let(:file_content) { '' } + + it 'returns nil for empty content' do + expect(detector.detect_source).to be_nil + end + end + end + + describe '#detect_source!' do + context 'with valid format' do + let(:file_content) { file_fixture('google/records.json').read } + + it 'returns the detected format' do + expect(detector.detect_source!).to eq(:google_records) + end + end + + context 'with unknown format' do + let(:file_content) { '{"unknown": "format"}' } + + it 'raises UnknownSourceError' do + expect { detector.detect_source! }.to raise_error( + Imports::SourceDetector::UnknownSourceError, + 'Unable to detect file format' + ) + end + end + end + + describe '.new_from_file_header' do + context 'with Google Records file' do + let(:fixture_path) { file_fixture('google/records.json').to_s } + + it 'detects source correctly from file path' do + detector = described_class.new_from_file_header(fixture_path) + expect(detector.detect_source).to eq(:google_records) + end + + it 'can detect source efficiently from file' do + detector = described_class.new_from_file_header(fixture_path) + + # Verify it can detect correctly using file-based approach + expect(detector.detect_source).to eq(:google_records) + end + end + + context 'with GeoJSON file' do + let(:fixture_path) { file_fixture('geojson/export.json').to_s } + + it 'detects source correctly from file path' do + detector = described_class.new_from_file_header(fixture_path) + expect(detector.detect_source).to eq(:geojson) + end + end + end + + describe 'detection accuracy with real fixture files' do + shared_examples 'detects format correctly' do |expected_format, fixture_path| + it "detects #{expected_format} format for #{fixture_path}" do + file_content = file_fixture(fixture_path).read + filename = File.basename(fixture_path) + detector = described_class.new(file_content, filename) + + expect(detector.detect_source).to eq(expected_format) + end + end + + # Test various Google Semantic History variations + include_examples 'detects format correctly', :google_semantic_history, 'google/location-history/with_activitySegment_with_startLocation.json' + include_examples 'detects format correctly', :google_semantic_history, 'google/location-history/with_placeVisit_with_location_with_coordinates.json' + + # Test GeoJSON variations + include_examples 'detects format correctly', :geojson, 'geojson/export_same_points.json' + include_examples 'detects format correctly', :geojson, 'geojson/gpslogger_example.json' + + # Test GPX files + include_examples 'detects format correctly', :gpx, 'gpx/arc_example.gpx' + include_examples 'detects format correctly', :gpx, 'gpx/garmin_example.gpx' + include_examples 'detects format correctly', :gpx, 'gpx/gpx_track_multiple_segments.gpx' + end +end diff --git a/spec/services/location_search/geocoding_service_spec.rb b/spec/services/location_search/geocoding_service_spec.rb new file mode 100644 index 00000000..ddbfa699 --- /dev/null +++ b/spec/services/location_search/geocoding_service_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::GeocodingService do + let(:query) { 'Kaufland Berlin' } + let(:service) { described_class.new(query) } + + describe '#search' do + context 'with valid query' do + let(:mock_geocoder_result) do + double( + 'Geocoder::Result', + latitude: 52.5200, + longitude: 13.4050, + address: 'Kaufland, Alexanderplatz 1, Berlin', + data: { + 'type' => 'shop', + 'osm_id' => '12345', + 'place_rank' => 30, + 'importance' => 0.8 + } + ) + end + + before do + allow(Geocoder).to receive(:search).and_return([mock_geocoder_result]) + allow(Geocoder.config).to receive(:lookup).and_return(:photon) + end + + it 'returns normalized geocoding results' do + results = service.search + + expect(results).to be_an(Array) + expect(results.first).to include( + lat: 52.5200, + lon: 13.4050, + name: 'Kaufland', + address: 'Kaufland, Alexanderplatz 1, Berlin', + type: 'shop' + ) + end + + it 'includes provider data' do + results = service.search + + expect(results.first[:provider_data]).to include( + osm_id: '12345', + place_rank: 30, + importance: 0.8 + ) + end + + it 'limits results to MAX_RESULTS' do + expect(Geocoder).to receive(:search).with(query, limit: 10) + + service.search + end + end + + context 'with blank query' do + let(:service) { described_class.new('') } + + it 'returns empty array' do + expect(service.search).to eq([]) + end + end + + context 'when Geocoder returns no results' do + before do + allow(Geocoder).to receive(:search).and_return([]) + end + + it 'returns empty array' do + expect(service.search).to eq([]) + end + end + + context 'when Geocoder raises an error' do + before do + allow(Geocoder).to receive(:search).and_raise(StandardError.new('Geocoding error')) + end + + it 'handles error gracefully and returns empty array' do + expect(service.search).to eq([]) + end + end + + context 'with invalid coordinates' do + let(:invalid_result) do + double( + 'Geocoder::Result', + latitude: 91.0, # Invalid latitude + longitude: 13.4050, + address: 'Invalid location', + data: {} + ) + end + + let(:valid_result) do + double( + 'Geocoder::Result', + latitude: 52.5200, + longitude: 13.4050, + address: 'Valid location', + data: {} + ) + end + + before do + allow(Geocoder).to receive(:search).and_return([invalid_result, valid_result]) + end + + it 'filters out results with invalid coordinates' do + results = service.search + + expect(results.length).to eq(1) + expect(results.first[:lat]).to eq(52.5200) + end + end + + describe '#deduplicate_results' do + let(:duplicate_results) do + [ + { + lat: 52.5200, + lon: 13.4050, + name: 'Location 1', + address: 'Address 1', + type: 'shop', + provider_data: {} + }, + { + lat: 52.5201, # Within 100m of first location + lon: 13.4051, + name: 'Location 2', + address: 'Address 2', + type: 'shop', + provider_data: {} + } + ] + end + + let(:mock_results) do + duplicate_results.map do |result| + double( + 'Geocoder::Result', + latitude: result[:lat], + longitude: result[:lon], + address: result[:address], + data: { 'type' => result[:type] } + ) + end + end + + before do + allow(Geocoder).to receive(:search).and_return(mock_results) + end + + it 'removes locations within 100m of each other' do + service = described_class.new('test') + results = service.search + + expect(results.length).to eq(1) + expect(results.first[:name]).to eq('Address 1') + end + end + end + + describe '#provider_name' do + before do + allow(Geocoder.config).to receive(:lookup).and_return(:nominatim) + end + + it 'returns the current geocoding provider name' do + expect(service.provider_name).to eq('Nominatim') + end + end +end \ No newline at end of file diff --git a/spec/services/location_search/point_finder_spec.rb b/spec/services/location_search/point_finder_spec.rb new file mode 100644 index 00000000..bd74f5da --- /dev/null +++ b/spec/services/location_search/point_finder_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::PointFinder do + let(:user) { create(:user) } + let(:service) { described_class.new(user, search_params) } + let(:search_params) { { latitude: 52.5200, longitude: 13.4050 } } + + describe '#call' do + context 'with valid coordinates' do + let(:mock_matching_points) do + [ + { + id: 1, + timestamp: 1711814700, + coordinates: [52.5201, 13.4051], + distance_meters: 45.5, + date: '2024-03-20T18:45:00Z' + } + ] + end + + let(:mock_visits) do + [ + { + timestamp: 1711814700, + date: '2024-03-20T18:45:00Z', + coordinates: [52.5201, 13.4051], + distance_meters: 45.5, + duration_estimate: '~25m', + points_count: 1 + } + ] + end + + before do + allow_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near).and_return(mock_matching_points) + + allow_any_instance_of(LocationSearch::ResultAggregator) + .to receive(:group_points_into_visits).and_return(mock_visits) + end + + it 'returns search results with location data' do + result = service.call + + expect(result[:locations]).to be_an(Array) + expect(result[:locations].first).to include( + coordinates: [52.5200, 13.4050], + total_visits: 1 + ) + end + + it 'calls spatial matcher with correct coordinates and radius' do + expect_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near) + .with(user, 52.5200, 13.4050, 500, { date_from: nil, date_to: nil }) + + service.call + end + + context 'with custom radius override' do + let(:search_params) { { latitude: 52.5200, longitude: 13.4050, radius_override: 150 } } + + it 'uses custom radius when override provided' do + expect_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near) + .with(user, anything, anything, 150, anything) + + service.call + end + end + + context 'with date filtering' do + let(:search_params) do + { + latitude: 52.5200, + longitude: 13.4050, + date_from: Date.parse('2024-01-01'), + date_to: Date.parse('2024-03-31') + } + end + + it 'passes date filters to spatial matcher' do + expect_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near) + .with(user, anything, anything, anything, { + date_from: Date.parse('2024-01-01'), + date_to: Date.parse('2024-03-31') + }) + + service.call + end + end + + context 'with limit parameter' do + let(:search_params) { { latitude: 52.5200, longitude: 13.4050, limit: 10 } } + let(:many_visits) { Array.new(15) { |i| { timestamp: i, date: "2024-01-#{i+1}T12:00:00Z" } } } + + before do + allow_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near).and_return([{}]) + + allow_any_instance_of(LocationSearch::ResultAggregator) + .to receive(:group_points_into_visits).and_return(many_visits) + end + + it 'limits the number of visits returned' do + result = service.call + + expect(result[:locations].first[:visits].length).to eq(10) + end + end + end + + context 'when no matching points found' do + let(:search_params) { { latitude: 52.5200, longitude: 13.4050 } } + + before do + allow_any_instance_of(LocationSearch::SpatialMatcher) + .to receive(:find_points_near).and_return([]) + end + + it 'excludes locations with no visits' do + result = service.call + + expect(result[:locations]).to be_empty + expect(result[:total_locations]).to eq(0) + end + end + + context 'when coordinates are missing' do + let(:search_params) { {} } + + it 'returns empty result without calling services' do + expect(LocationSearch::SpatialMatcher).not_to receive(:new) + + result = service.call + + expect(result[:locations]).to be_empty + end + end + + context 'when only latitude is provided' do + let(:search_params) { { latitude: 52.5200 } } + + it 'returns empty result' do + result = service.call + + expect(result[:locations]).to be_empty + end + end + + context 'when only longitude is provided' do + let(:search_params) { { longitude: 13.4050 } } + + it 'returns empty result' do + result = service.call + + expect(result[:locations]).to be_empty + end + end + end +end diff --git a/spec/services/location_search/result_aggregator_spec.rb b/spec/services/location_search/result_aggregator_spec.rb new file mode 100644 index 00000000..99d85d24 --- /dev/null +++ b/spec/services/location_search/result_aggregator_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::ResultAggregator do + let(:service) { described_class.new } + + describe '#group_points_into_visits' do + context 'with empty points array' do + it 'returns empty array' do + result = service.group_points_into_visits([]) + expect(result).to eq([]) + end + end + + context 'with single point' do + let(:single_point) do + { + id: 1, + timestamp: 1711814700, + coordinates: [52.5200, 13.4050], + distance_meters: 45.5, + accuracy: 10, + date: '2024-03-20T18:45:00Z', + city: 'Berlin', + country: 'Germany', + altitude: 100 + } + end + + it 'creates a single visit' do + result = service.group_points_into_visits([single_point]) + + expect(result.length).to eq(1) + visit = result.first + expect(visit[:timestamp]).to eq(1711814700) + expect(visit[:coordinates]).to eq([52.5200, 13.4050]) + expect(visit[:points_count]).to eq(1) + end + + it 'estimates duration for single point visits' do + result = service.group_points_into_visits([single_point]) + + visit = result.first + expect(visit[:duration_estimate]).to eq('~15 minutes') + expect(visit[:visit_details][:duration_minutes]).to eq(15) + end + end + + context 'with consecutive points' do + let(:consecutive_points) do + [ + { + id: 1, + timestamp: 1711814700, # 18:45 + coordinates: [52.5200, 13.4050], + distance_meters: 45.5, + accuracy: 10, + date: '2024-03-20T18:45:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 2, + timestamp: 1711816500, # 19:15 (30 minutes later) + coordinates: [52.5201, 13.4051], + distance_meters: 48.2, + accuracy: 8, + date: '2024-03-20T19:15:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 3, + timestamp: 1711817400, # 19:30 (15 minutes later) + coordinates: [52.5199, 13.4049], + distance_meters: 42.1, + accuracy: 12, + date: '2024-03-20T19:30:00Z', + city: 'Berlin', + country: 'Germany' + } + ] + end + + it 'groups consecutive points into single visit' do + result = service.group_points_into_visits(consecutive_points) + + expect(result.length).to eq(1) + visit = result.first + expect(visit[:points_count]).to eq(3) + end + + it 'calculates visit duration from start to end' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + expect(visit[:duration_estimate]).to eq('~45 minutes') + expect(visit[:visit_details][:duration_minutes]).to eq(45) + end + + it 'uses most accurate point coordinates' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + # Point with accuracy 8 should be selected + expect(visit[:coordinates]).to eq([52.5201, 13.4051]) + expect(visit[:accuracy_meters]).to eq(8) + end + + it 'calculates average distance' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + expected_avg = (45.5 + 48.2 + 42.1) / 3 + expect(visit[:distance_meters]).to eq(expected_avg.round(2)) + end + + it 'sets correct start and end times' do + result = service.group_points_into_visits(consecutive_points) + + visit = result.first + expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z') + expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z') + end + end + + context 'with separate visits (time gaps)' do + let(:separate_visits_points) do + [ + { + id: 1, + timestamp: 1711814700, # 18:45 + coordinates: [52.5200, 13.4050], + distance_meters: 45.5, + accuracy: 10, + date: '2024-03-20T18:45:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 2, + timestamp: 1711816500, # 19:15 (30 minutes later - within threshold) + coordinates: [52.5201, 13.4051], + distance_meters: 48.2, + accuracy: 8, + date: '2024-03-20T19:15:00Z', + city: 'Berlin', + country: 'Germany' + }, + { + id: 3, + timestamp: 1711820100, # 20:15 (60 minutes after last point - exceeds threshold) + coordinates: [52.5199, 13.4049], + distance_meters: 42.1, + accuracy: 12, + date: '2024-03-20T20:15:00Z', + city: 'Berlin', + country: 'Germany' + } + ] + end + + it 'creates separate visits when time gap exceeds threshold' do + result = service.group_points_into_visits(separate_visits_points) + + expect(result.length).to eq(2) + expect(result.first[:points_count]).to eq(1) # Most recent visit (20:15) + expect(result.last[:points_count]).to eq(2) # Earlier visit (18:45-19:15) + end + + it 'orders visits by timestamp descending (most recent first)' do + result = service.group_points_into_visits(separate_visits_points) + + expect(result.first[:timestamp]).to be > result.last[:timestamp] + end + end + + context 'with duration formatting' do + let(:points_with_various_durations) do + # Helper to create points with time differences + base_time = 1711814700 + + [ + # Short visit (25 minutes) - 2 points 25 minutes apart + { id: 1, timestamp: base_time, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' }, + { id: 2, timestamp: base_time + 25 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:10:00Z' }, + + # Long visit (2 hours 15 minutes) - points every 15 minutes to stay within 30min threshold + { id: 3, timestamp: base_time + 70 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T19:55:00Z' }, + { id: 4, timestamp: base_time + 85 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:10:00Z' }, + { id: 5, timestamp: base_time + 100 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:25:00Z' }, + { id: 6, timestamp: base_time + 115 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:40:00Z' }, + { id: 7, timestamp: base_time + 130 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T20:55:00Z' }, + { id: 8, timestamp: base_time + 145 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:10:00Z' }, + { id: 9, timestamp: base_time + 160 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:25:00Z' }, + { id: 10, timestamp: base_time + 175 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:40:00Z' }, + { id: 11, timestamp: base_time + 190 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T21:55:00Z' }, + { id: 12, timestamp: base_time + 205 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T22:10:00Z' } + ] + end + + it 'formats duration correctly for minutes only' do + short_visit_points = points_with_various_durations.take(2) + result = service.group_points_into_visits(short_visit_points) + + expect(result.first[:duration_estimate]).to eq('~25 minutes') + end + + it 'formats duration correctly for hours and minutes' do + long_visit_points = points_with_various_durations.drop(2) + result = service.group_points_into_visits(long_visit_points) + + expect(result.first[:duration_estimate]).to eq('~2 hours 15 minutes') + end + + it 'formats duration correctly for hours only' do + # Create points within threshold but exactly 2 hours apart from first to last + exact_hour_points = [ + { id: 1, timestamp: 1711814700, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' }, + { id: 2, timestamp: 1711814700 + 25 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:10:00Z' }, + { id: 3, timestamp: 1711814700 + 50 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:35:00Z' }, + { id: 4, timestamp: 1711814700 + 75 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:00:00Z' }, + { id: 5, timestamp: 1711814700 + 100 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:25:00Z' }, + { id: 6, timestamp: 1711814700 + 120 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:45:00Z' } + ] + + result = service.group_points_into_visits(exact_hour_points) + + expect(result.first[:duration_estimate]).to eq('~2 hours') + end + end + + context 'with altitude data' do + let(:points_with_altitude) do + [ + { + id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050], + accuracy: 10, distance_meters: 50, altitude: 100, + date: '2024-03-20T18:45:00Z' + }, + { + id: 2, timestamp: 1711815600, coordinates: [52.5201, 13.4051], + accuracy: 10, distance_meters: 50, altitude: 105, + date: '2024-03-20T19:00:00Z' + }, + { + id: 3, timestamp: 1711816500, coordinates: [52.5199, 13.4049], + accuracy: 10, distance_meters: 50, altitude: 95, + date: '2024-03-20T19:15:00Z' + } + ] + end + + it 'includes altitude range in visit details' do + result = service.group_points_into_visits(points_with_altitude) + + visit = result.first + expect(visit[:visit_details][:altitude_range]).to eq('95m - 105m') + end + + context 'with same altitude for all points' do + before do + points_with_altitude.each { |p| p[:altitude] = 100 } + end + + it 'shows single altitude value' do + result = service.group_points_into_visits(points_with_altitude) + + visit = result.first + expect(visit[:visit_details][:altitude_range]).to eq('100m') + end + end + + context 'with missing altitude data' do + before do + points_with_altitude.each { |p| p.delete(:altitude) } + end + + it 'handles missing altitude gracefully' do + result = service.group_points_into_visits(points_with_altitude) + + visit = result.first + expect(visit[:visit_details][:altitude_range]).to be_nil + end + end + end + + context 'with unordered points' do + let(:unordered_points) do + [ + { id: 3, timestamp: 1711817400, coordinates: [52.5199, 13.4049], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:30:00Z' }, + { id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050], accuracy: 10, distance_meters: 50, date: '2024-03-20T18:45:00Z' }, + { id: 2, timestamp: 1711816500, coordinates: [52.5201, 13.4051], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:15:00Z' } + ] + end + + it 'handles unordered input correctly' do + result = service.group_points_into_visits(unordered_points) + + visit = result.first + expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z') + expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z') + end + end + end +end \ No newline at end of file diff --git a/spec/services/location_search/spatial_matcher_spec.rb b/spec/services/location_search/spatial_matcher_spec.rb new file mode 100644 index 00000000..a9550873 --- /dev/null +++ b/spec/services/location_search/spatial_matcher_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe LocationSearch::SpatialMatcher do + let(:service) { described_class.new } + let(:user) { create(:user) } + let(:latitude) { 52.5200 } + let(:longitude) { 13.4050 } + let(:radius_meters) { 100 } + + describe '#find_points_near' do + let!(:near_point) do + create(:point, + user: user, + lonlat: "POINT(13.4051 52.5201)", + timestamp: 1.hour.ago.to_i, + city: 'Berlin', + country: 'Germany', + altitude: 100, + accuracy: 5 + ) + end + + let!(:far_point) do + create(:point, + user: user, + lonlat: "POINT(13.5000 52.6000)", + timestamp: 2.hours.ago.to_i + ) + end + + let!(:other_user_point) do + create(:point, + user: create(:user), + lonlat: "POINT(13.4051 52.5201)", + timestamp: 30.minutes.ago.to_i + ) + end + + context 'with points within radius' do + it 'returns points within the specified radius' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.length).to eq(1) + expect(results.first[:id]).to eq(near_point.id) + end + + it 'excludes points outside the radius' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).not_to include(far_point.id) + end + + it 'only includes points from the specified user' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).not_to include(other_user_point.id) + end + + it 'includes calculated distance' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.first[:distance_meters]).to be_a(Float) + expect(results.first[:distance_meters]).to be < radius_meters + end + + it 'includes point attributes' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + point = results.first + expect(point).to include( + id: near_point.id, + timestamp: near_point.timestamp, + coordinates: [52.5201, 13.4051], + city: 'Berlin', + country: 'Germany', + altitude: 100, + accuracy: 5 + ) + end + + it 'includes ISO8601 formatted date' do + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.first[:date]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + end + + it 'orders results by timestamp descending (most recent first)' do + # Create another nearby point with older timestamp + older_point = create(:point, + user: user, + lonlat: "POINT(13.4049 52.5199)", + timestamp: 3.hours.ago.to_i + ) + + results = service.find_points_near(user, latitude, longitude, radius_meters) + + expect(results.first[:id]).to eq(near_point.id) # More recent + expect(results.last[:id]).to eq(older_point.id) # Older + end + end + + context 'with date filtering' do + let(:date_options) do + { + date_from: 2.days.ago.to_date, + date_to: Date.current + } + end + + let!(:old_point) do + create(:point, + user: user, + lonlat: "POINT(13.4051 52.5201)", + timestamp: 1.week.ago.to_i + ) + end + + it 'filters points by date range' do + results = service.find_points_near(user, latitude, longitude, radius_meters, date_options) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).to include(near_point.id) + expect(point_ids).not_to include(old_point.id) + end + + context 'with only date_from' do + let(:date_options) { { date_from: 2.hours.ago.to_date } } + + it 'includes points after date_from' do + results = service.find_points_near(user, latitude, longitude, radius_meters, date_options) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).to include(near_point.id) + end + end + + context 'with only date_to' do + let(:date_options) { { date_to: 2.days.ago.to_date } } + + it 'includes points before date_to' do + results = service.find_points_near(user, latitude, longitude, radius_meters, date_options) + + point_ids = results.map { |r| r[:id] } + expect(point_ids).to include(old_point.id) + expect(point_ids).not_to include(near_point.id) + end + end + end + + context 'with no points within radius' do + it 'returns empty array' do + results = service.find_points_near(user, 60.0, 30.0, 100) # Far away coordinates + + expect(results).to be_empty + end + end + + context 'with edge cases' do + it 'handles points at the exact radius boundary' do + # This test would require creating a point at exactly 100m distance + # For simplicity, we'll test with a very small radius that should exclude our test point + results = service.find_points_near(user, latitude, longitude, 1) # 1 meter radius + + expect(results).to be_empty + end + + it 'handles negative coordinates' do + # Create point with negative coordinates + negative_point = create(:point, + user: user, + lonlat: "POINT(151.2093 -33.8688)", + timestamp: 1.hour.ago.to_i + ) + + results = service.find_points_near(user, -33.8688, 151.2093, 1000) + + expect(results.length).to eq(1) + expect(results.first[:id]).to eq(negative_point.id) + end + + it 'handles coordinates near poles' do + # Create point near north pole + polar_point = create(:point, + user: user, + lonlat: "POINT(0.0 89.0)", + timestamp: 1.hour.ago.to_i + ) + + results = service.find_points_near(user, 89.0, 0.0, 1000) + + expect(results.length).to eq(1) + expect(results.first[:id]).to eq(polar_point.id) + end + end + + context 'with large datasets' do + before do + # Create many points to test performance + 50.times do |i| + create(:point, + user: user, + lonlat: "POINT(#{longitude + (i * 0.0001)} #{latitude + (i * 0.0001)})", # Spread points slightly + timestamp: i.hours.ago.to_i + ) + end + end + + it 'efficiently queries large datasets' do + start_time = Time.current + + results = service.find_points_near(user, latitude, longitude, 1000) + + query_time = Time.current - start_time + expect(query_time).to be < 1.0 # Should complete within 1 second + expect(results.length).to be > 40 # Should find most of the points + end + end + end +end diff --git a/spec/services/maps/bounds_calculator_spec.rb b/spec/services/maps/bounds_calculator_spec.rb new file mode 100644 index 00000000..8e26508d --- /dev/null +++ b/spec/services/maps/bounds_calculator_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Maps::BoundsCalculator do + describe '.call' do + subject(:calculate_bounds) do + described_class.new(user:, start_date:, end_date:).call + end + + let(:user) { create(:user) } + let(:start_date) { '2024-06-01T00:00:00Z' } + let(:end_date) { '2024-06-30T23:59:59Z' } + + context 'with valid user and date range' do + before do + # Create test points within the date range + create(:point, user:, latitude: 40.6, longitude: -74.1, + timestamp: Time.new(2024, 6, 1, 12, 0).to_i) + create(:point, user:, latitude: 40.8, longitude: -73.9, + timestamp: Time.new(2024, 6, 30, 15, 0).to_i) + create(:point, user:, latitude: 40.7, longitude: -74.0, + timestamp: Time.new(2024, 6, 15, 10, 0).to_i) + end + + it 'returns success with bounds data' do + expect(calculate_bounds).to match( + { + success: true, + data: { + min_lat: 40.6, + max_lat: 40.8, + min_lng: -74.1, + max_lng: -73.9, + point_count: 3 + } + } + ) + end + end + + context 'with no points in date range' do + before do + # Create points outside the date range + create(:point, user:, latitude: 40.7, longitude: -74.0, + timestamp: Time.new(2024, 5, 15, 10, 0).to_i) + end + + it 'returns failure with no data message' do + expect(calculate_bounds).to match( + { + success: false, + error: 'No data found for the specified date range', + point_count: 0 + } + ) + end + end + + context 'with no user' do + let(:user) { nil } + + it 'raises NoUserFoundError' do + expect { calculate_bounds }.to raise_error( + Maps::BoundsCalculator::NoUserFoundError, + 'No user found' + ) + end + end + + context 'with no start date' do + let(:start_date) { nil } + + it 'raises NoDateRangeError' do + expect { calculate_bounds }.to raise_error( + Maps::BoundsCalculator::NoDateRangeError, + 'No date range specified' + ) + end + end + + context 'with no end date' do + let(:end_date) { nil } + + it 'raises NoDateRangeError' do + expect { calculate_bounds }.to raise_error( + Maps::BoundsCalculator::NoDateRangeError, + 'No date range specified' + ) + end + end + + context 'with invalid date parsing' do + let(:start_date) { 'invalid-date' } + + it 'raises ArgumentError for invalid dates' do + expect { calculate_bounds }.to raise_error(ArgumentError, 'Invalid date format: invalid-date') + end + end + + context 'with timestamp format dates' do + let(:start_date) { 1_717_200_000 } + let(:end_date) { 1_719_791_999 } + + before do + create(:point, user:, latitude: 41.0, longitude: -74.5, + timestamp: Time.new(2024, 6, 5, 9, 0).to_i) + end + + it 'handles timestamp format correctly' do + result = calculate_bounds + expect(result[:success]).to be true + expect(result[:data][:point_count]).to eq(1) + end + end + end +end diff --git a/spec/services/maps/hexagon_center_manager_spec.rb b/spec/services/maps/hexagon_center_manager_spec.rb new file mode 100644 index 00000000..472ad520 --- /dev/null +++ b/spec/services/maps/hexagon_center_manager_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Maps::HexagonCenterManager do + describe '.call' do + subject(:manage_centers) { described_class.new(stat:, user:).call } + + let(:user) { create(:user) } + let(:target_user) { user } + + context 'with pre-calculated hexagon centers' do + let(:pre_calculated_centers) do + [ + ['8a1fb46622dffff', 5, 1_717_200_000, 1_717_203_600], # h3_index, count, earliest, latest timestamps + ['8a1fb46622e7fff', 3, 1_717_210_000, 1_717_213_600], + ['8a1fb46632dffff', 8, 1_717_220_000, 1_717_223_600] + ] + end + let(:stat) { create(:stat, user:, year: 2024, month: 6, h3_hex_ids: pre_calculated_centers) } + + it 'returns success with pre-calculated data' do + result = manage_centers + + expect(result[:success]).to be true + expect(result[:pre_calculated]).to be true + expect(result[:data]['type']).to eq('FeatureCollection') + expect(result[:data]['features'].length).to eq(3) + expect(result[:data]['metadata']['pre_calculated']).to be true + expect(result[:data]['metadata']['count']).to eq(3) + expect(result[:data]['metadata']['user_id']).to eq(target_user.id) + end + + it 'generates proper hexagon features from centers' do + result = manage_centers + features = result[:data]['features'] + + features.each_with_index do |feature, index| + expect(feature['type']).to eq('Feature') + expect(feature['id']).to eq(index + 1) + expect(feature['geometry']['type']).to eq('Polygon') + expect(feature['geometry']['coordinates'].first.length).to eq(7) # 6 vertices + closing + + properties = feature['properties'] + expect(properties['hex_id']).to eq(index + 1) + expect(properties['earliest_point']).to be_present + expect(properties['latest_point']).to be_present + end + end + end + + + context 'with no stat' do + let(:stat) { nil } + + it 'returns nil' do + expect(manage_centers).to be_nil + end + end + + context 'with stat but no hexagon_centers' do + let(:stat) { create(:stat, user:, year: 2024, month: 6, h3_hex_ids: nil) } + + it 'returns nil' do + expect(manage_centers).to be_nil + end + end + + context 'with empty hexagon_centers' do + let(:stat) { create(:stat, user:, year: 2024, month: 6, h3_hex_ids: []) } + + it 'returns nil' do + expect(manage_centers).to be_nil + end + end + end +end diff --git a/spec/services/maps/hexagon_polygon_generator_spec.rb b/spec/services/maps/hexagon_polygon_generator_spec.rb new file mode 100644 index 00000000..5d8466d5 --- /dev/null +++ b/spec/services/maps/hexagon_polygon_generator_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Maps::HexagonPolygonGenerator do + describe '.call' do + subject(:generate_polygon) do + described_class.new(h3_index: h3_index).call + end + + # Valid H3 index for NYC area (resolution 6) + let(:h3_index) { '8a1fb46622dffff' } + + it 'returns a polygon geometry' do + result = generate_polygon + + expect(result['type']).to eq('Polygon') + expect(result['coordinates']).to be_an(Array) + expect(result['coordinates'].length).to eq(1) # One ring + end + + it 'generates a hexagon with 7 coordinate pairs (6 vertices + closing)' do + result = generate_polygon + coordinates = result['coordinates'].first + + expect(coordinates.length).to eq(7) # 6 vertices + closing vertex + expect(coordinates.first).to eq(coordinates.last) # Closed polygon + end + + it 'generates unique vertices' do + result = generate_polygon + coordinates = result['coordinates'].first + + # Remove the closing vertex for uniqueness check + unique_vertices = coordinates[0..5] + expect(unique_vertices.uniq.length).to eq(6) # All vertices should be unique + end + + it 'generates vertices in proper [lng, lat] format' do + result = generate_polygon + coordinates = result['coordinates'].first + + coordinates.each do |vertex| + lng, lat = vertex + expect(lng).to be_a(Float) + expect(lat).to be_a(Float) + expect(lng).to be_between(-180, 180) + expect(lat).to be_between(-90, 90) + end + end + + context 'with hex string index' do + let(:h3_index) { '8a1fb46622dffff' } + + it 'handles hex string format' do + result = generate_polygon + expect(result['type']).to eq('Polygon') + expect(result['coordinates'].first.length).to eq(7) + end + end + + context 'with integer index' do + let(:h3_index) { 0x8a1fb46622dffff } + + it 'handles integer format' do + result = generate_polygon + expect(result['type']).to eq('Polygon') + expect(result['coordinates'].first.length).to eq(7) + end + end + + context 'when H3 operations fail' do + before do + allow(H3).to receive(:to_boundary).and_raise(StandardError, 'H3 error') + end + + it 'raises the H3 error' do + expect { generate_polygon }.to raise_error(StandardError, 'H3 error') + end + end + + context 'with invalid H3 index' do + let(:h3_index) { nil } + + it 'raises an error for invalid index' do + expect { generate_polygon }.to raise_error(TypeError) + end + end + end +end \ No newline at end of file diff --git a/spec/services/maps/hexagon_request_handler_spec.rb b/spec/services/maps/hexagon_request_handler_spec.rb new file mode 100644 index 00000000..df3e6988 --- /dev/null +++ b/spec/services/maps/hexagon_request_handler_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Maps::HexagonRequestHandler do + describe '.call' do + subject(:handle_request) do + described_class.new( + params: params, + user: user, + stat: stat, + start_date: start_date, + end_date: end_date + ).call + end + + let(:user) { create(:user) } + + context 'with authenticated user but no pre-calculated data' do + let(:stat) { nil } + let(:start_date) { '2024-06-01T00:00:00Z' } + let(:end_date) { '2024-06-30T23:59:59Z' } + let(:params) do + ActionController::Parameters.new( + { + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8, + start_date: start_date, + end_date: end_date + } + ) + end + + it 'returns empty feature collection when no pre-calculated data' do + result = handle_request + + expect(result).to be_a(Hash) + expect(result['type']).to eq('FeatureCollection') + expect(result['features']).to eq([]) + expect(result['metadata']['hexagon_count']).to eq(0) + expect(result['metadata']['source']).to eq('pre_calculated') + end + end + + context 'with public sharing UUID and pre-calculated centers' do + let(:pre_calculated_centers) do + [ + ['8a1fb46622dffff', 5, 1_717_200_000, 1_717_203_600], + ['8a1fb46622e7fff', 3, 1_717_210_000, 1_717_213_600] + ] + end + let(:stat) do + create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6, + h3_hex_ids: pre_calculated_centers) + end + let(:start_date) { Date.new(2024, 6, 1).beginning_of_day.iso8601 } + let(:end_date) { Date.new(2024, 6, 1).end_of_month.end_of_day.iso8601 } + let(:params) do + ActionController::Parameters.new( + { + uuid: stat.sharing_uuid, + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8 + } + ) + end + + it 'returns pre-calculated hexagon data' do + result = handle_request + + expect(result['type']).to eq('FeatureCollection') + expect(result['features'].length).to eq(2) + expect(result['metadata']['pre_calculated']).to be true + expect(result['metadata']['user_id']).to eq(user.id) + end + end + + context 'with public sharing UUID but no pre-calculated centers' do + let(:stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6) } + let(:start_date) { Date.new(2024, 6, 1).beginning_of_day.iso8601 } + let(:end_date) { Date.new(2024, 6, 1).end_of_month.end_of_day.iso8601 } + let(:params) do + ActionController::Parameters.new( + { + uuid: stat.sharing_uuid, + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8 + } + ) + end + + it 'returns empty feature collection when no pre-calculated centers' do + result = handle_request + + expect(result['type']).to eq('FeatureCollection') + expect(result['features']).to eq([]) + expect(result['metadata']['hexagon_count']).to eq(0) + expect(result['metadata']['source']).to eq('pre_calculated') + end + end + + context 'with stat containing empty h3_hex_ids data' do + let(:stat) do + create(:stat, :with_sharing_enabled, user:, year: 2024, month: 6, + h3_hex_ids: {}) + end + let(:start_date) { Date.new(2024, 6, 1).beginning_of_day.iso8601 } + let(:end_date) { Date.new(2024, 6, 1).end_of_month.end_of_day.iso8601 } + let(:params) do + ActionController::Parameters.new( + { + uuid: stat.sharing_uuid, + min_lon: -74.1, + min_lat: 40.6, + max_lon: -73.9, + max_lat: 40.8 + } + ) + end + + it 'returns empty feature collection for empty data' do + result = handle_request + + expect(result['type']).to eq('FeatureCollection') + expect(result['features']).to eq([]) + expect(result['metadata']['hexagon_count']).to eq(0) + expect(result['metadata']['source']).to eq('pre_calculated') + end + end + end +end diff --git a/spec/services/own_tracks/importer_spec.rb b/spec/services/own_tracks/importer_spec.rb index 842883f8..3305c9eb 100644 --- a/spec/services/own_tracks/importer_spec.rb +++ b/spec/services/own_tracks/importer_spec.rb @@ -23,7 +23,7 @@ RSpec.describe OwnTracks::Importer do it 'correctly writes attributes' do parser - point = Point.first + point = user.points.first expect(point.lonlat.x).to be_within(0.001).of(13.332) expect(point.lonlat.y).to be_within(0.001).of(52.225) expect(point.attributes.except('lonlat')).to include( @@ -75,7 +75,7 @@ RSpec.describe OwnTracks::Importer do it 'correctly converts speed' do parser - expect(Point.first.velocity).to eq('1.4') + expect(user.points.first.velocity).to eq('1.4') end end @@ -85,12 +85,6 @@ RSpec.describe OwnTracks::Importer do it 'creates points' do expect { parser }.to change { Point.count }.by(9) end - - it 'correctly writes attributes' do - parser - - point = Point.first - end end end end diff --git a/spec/services/photos/importer_spec.rb b/spec/services/photos/importer_spec.rb index 567898a3..67dd9b58 100644 --- a/spec/services/photos/importer_spec.rb +++ b/spec/services/photos/importer_spec.rb @@ -30,15 +30,18 @@ RSpec.describe Photos::Importer do it 'creates points with correct attributes' do service - expect(Point.first.lat.to_f).to eq(59.0000) - expect(Point.first.lon.to_f).to eq(30.0000) - expect(Point.first.timestamp).to eq(978_296_400) - expect(Point.first.import_id).to eq(import.id) + first_point = user.points.first + second_point = user.points.second - expect(Point.second.lat.to_f).to eq(55.0001) - expect(Point.second.lon.to_f).to eq(37.0001) - expect(Point.second.timestamp).to eq(978_296_400) - expect(Point.second.import_id).to eq(import.id) + expect(first_point.lat.to_f).to eq(59.0000) + expect(first_point.lon.to_f).to eq(30.0000) + expect(first_point.timestamp).to eq(978_296_400) + expect(first_point.import_id).to eq(import.id) + + expect(second_point.lat.to_f).to eq(55.0001) + expect(second_point.lon.to_f).to eq(37.0001) + expect(second_point.timestamp).to eq(978_296_400) + expect(second_point.import_id).to eq(import.id) end end diff --git a/spec/services/points/raw_data_lonlat_extractor_spec.rb b/spec/services/points/raw_data_lonlat_extractor_spec.rb index 9fcb3800..f8f8d18d 100644 --- a/spec/services/points/raw_data_lonlat_extractor_spec.rb +++ b/spec/services/points/raw_data_lonlat_extractor_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -36,7 +36,7 @@ RSpec.describe Points::RawDataLonlatExtractor do 'latitudeE7' => 512_345_678 } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -55,7 +55,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -74,7 +74,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -92,7 +92,7 @@ RSpec.describe Points::RawDataLonlatExtractor do 'lat' => 51.2345678 } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -111,7 +111,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -129,7 +129,7 @@ RSpec.describe Points::RawDataLonlatExtractor do 'latitude' => 51.2345678 } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } it 'extracts longitude and latitude correctly' do expect { described_class.new(point).call }.to \ @@ -148,7 +148,7 @@ RSpec.describe Points::RawDataLonlatExtractor do } } end - let(:point) { create(:point, user: user, raw_data: raw_data, longitude: nil, latitude: nil) } + let(:point) { create(:point, user: user, raw_data: raw_data, longitude: 0.0, latitude: 0.0) } # Mock the entire call method since service doesn't have nil check before do diff --git a/spec/services/points_limit_exceeded_spec.rb b/spec/services/points_limit_exceeded_spec.rb index fed8a880..293c7a27 100644 --- a/spec/services/points_limit_exceeded_spec.rb +++ b/spec/services/points_limit_exceeded_spec.rb @@ -24,20 +24,20 @@ RSpec.describe PointsLimitExceeded do context 'when user points count is equal to the limit' do before do - allow(user.tracked_points).to receive(:count).and_return(10) + allow(user).to receive(:points_count).and_return(10) end it { is_expected.to be true } it 'caches the result' do - expect(user.tracked_points).to receive(:count).once + expect(user).to receive(:points_count).once 2.times { described_class.new(user).call } end end context 'when user points count exceeds the limit' do before do - allow(user.tracked_points).to receive(:count).and_return(11) + allow(user).to receive(:points_count).and_return(11) end it { is_expected.to be true } @@ -45,7 +45,7 @@ RSpec.describe PointsLimitExceeded do context 'when user points count is below the limit' do before do - allow(user.tracked_points).to receive(:count).and_return(9) + allow(user).to receive(:points_count).and_return(9) end it { is_expected.to be false } diff --git a/spec/services/stats/hexagon_calculator_spec.rb b/spec/services/stats/hexagon_calculator_spec.rb new file mode 100644 index 00000000..0cf221ff --- /dev/null +++ b/spec/services/stats/hexagon_calculator_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Stats::HexagonCalculator do + describe '#call' do + subject(:calculate_hexagons) do + described_class.new(user.id, year, month).call(h3_resolution: h3_resolution) + end + + let(:user) { create(:user) } + let(:year) { 2024 } + let(:month) { 1 } + let(:h3_resolution) { 8 } + + context 'when there are no points' do + it 'returns empty array' do + expect(calculate_hexagons).to eq([]) + end + end + + context 'when there are points' do + let(:timestamp1) { DateTime.new(year, month, 1, 12).to_i } + let(:timestamp2) { DateTime.new(year, month, 1, 13).to_i } + let!(:import) { create(:import, user:) } + let!(:point1) do + create(:point, + user:, + import:, + timestamp: timestamp1, + lonlat: 'POINT(14.452712811406352 52.107902115161316)') + end + let!(:point2) do + create(:point, + user:, + import:, + timestamp: timestamp2, + lonlat: 'POINT(14.453712811406352 52.108902115161316)') + end + + it 'returns H3 hexagon data' do + result = calculate_hexagons + + expect(result).to be_an(Array) + expect(result).not_to be_empty + + # Each record should have: [h3_index_string, point_count, earliest_timestamp, latest_timestamp] + result.each do |record| + expect(record).to be_an(Array) + expect(record.size).to eq(4) + expect(record[0]).to be_a(String) # H3 index as hex string + expect(record[1]).to be_a(Integer) # Point count + expect(record[2]).to be_a(Integer) # Earliest timestamp + expect(record[3]).to be_a(Integer) # Latest timestamp + end + end + + it 'aggregates points correctly' do + result = calculate_hexagons + + total_points = result.sum { |record| record[1] } + expect(total_points).to eq(2) + end + + context 'when H3 raises an error' do + before do + allow(H3).to receive(:from_geo_coordinates).and_raise(StandardError, 'H3 error') + end + + it 'raises PostGISError' do + expect do + calculate_hexagons + end.to raise_error(Stats::HexagonCalculator::PostGISError, /Failed to calculate H3 hexagon centers/) + end + + it 'reports the exception' do + expect(ExceptionReporter).to receive(:call) if defined?(ExceptionReporter) + + expect { calculate_hexagons }.to raise_error(Stats::HexagonCalculator::PostGISError) + end + end + end + end +end diff --git a/spec/services/tracks/boundary_detector_spec.rb b/spec/services/tracks/boundary_detector_spec.rb new file mode 100644 index 00000000..7a02b205 --- /dev/null +++ b/spec/services/tracks/boundary_detector_spec.rb @@ -0,0 +1,341 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::BoundaryDetector do + let(:user) { create(:user) } + let(:detector) { described_class.new(user) } + let(:safe_settings) { user.safe_settings } + + before do + # Spy on user settings - ensure we're working with the same object + allow(user).to receive(:safe_settings).and_return(safe_settings) + allow(safe_settings).to receive(:minutes_between_routes).and_return(30) + allow(safe_settings).to receive(:meters_between_routes).and_return(500) + + # Stub Geocoder for consistent distance calculations + allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(100) # 100 meters + allow(Point).to receive(:calculate_distance_for_array_geocoder).and_return(1000) # 1000 meters + end + + describe '#initialize' do + it 'sets the user' do + expect(detector.user).to eq(user) + end + end + + describe '#resolve_cross_chunk_tracks' do + context 'when no recent tracks exist' do + it 'returns 0' do + expect(detector.resolve_cross_chunk_tracks).to eq(0) + end + + it 'does not log boundary operations when no candidates found' do + # This test may log other things, but should not log boundary-related messages + result = detector.resolve_cross_chunk_tracks + expect(result).to eq(0) + end + end + + context 'when no boundary candidates are found' do + let!(:track1) { create(:track, user: user, created_at: 30.minutes.ago) } + let!(:track2) { create(:track, user: user, created_at: 25.minutes.ago) } + + before do + # Create points that are far apart (no spatial connection) + create(:point, user: user, track: track1, latitude: 40.0, longitude: -74.0, timestamp: 2.hours.ago.to_i) + create(:point, user: user, track: track2, latitude: 41.0, longitude: -73.0, timestamp: 1.hour.ago.to_i) + + # Mock distance to be greater than threshold + allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(1000) # 1000 meters > 500 threshold + end + + it 'returns 0' do + expect(detector.resolve_cross_chunk_tracks).to eq(0) + end + end + + context 'when boundary candidates exist' do + let!(:track1) { create(:track, user: user, created_at: 30.minutes.ago, start_at: 2.hours.ago, end_at: 1.5.hours.ago) } + let!(:track2) { create(:track, user: user, created_at: 25.minutes.ago, start_at: 1.hour.ago, end_at: 30.minutes.ago) } + + let!(:point1_start) { create(:point, user: user, track: track1, latitude: 40.0, longitude: -74.0, timestamp: 2.hours.ago.to_i) } + let!(:point1_end) { create(:point, user: user, track: track1, latitude: 40.01, longitude: -74.01, timestamp: 1.5.hours.ago.to_i) } + let!(:point2_start) { create(:point, user: user, track: track2, latitude: 40.01, longitude: -74.01, timestamp: 1.hour.ago.to_i) } + let!(:point2_end) { create(:point, user: user, track: track2, latitude: 40.02, longitude: -74.02, timestamp: 30.minutes.ago.to_i) } + + before do + # Mock close distance for connected tracks + allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(100) # Within 500m threshold + end + + it 'finds and resolves boundary tracks' do + expect(detector.resolve_cross_chunk_tracks).to eq(1) + end + + it 'creates a merged track with all points' do + expect { + detector.resolve_cross_chunk_tracks + }.to change { user.tracks.count }.by(-1) # 2 tracks become 1 + + merged_track = user.tracks.first + expect(merged_track.points.count).to eq(4) # All points from both tracks + end + + it 'deletes original tracks' do + original_track_ids = [track1.id, track2.id] + + detector.resolve_cross_chunk_tracks + + expect(Track.where(id: original_track_ids)).to be_empty + end + end + + context 'when merge fails' do + let!(:track1) { create(:track, user: user, created_at: 30.minutes.ago) } + let!(:track2) { create(:track, user: user, created_at: 25.minutes.ago) } + + # Ensure tracks have points so merge gets to the create_track_from_points step + let!(:point1) { create(:point, user: user, track: track1, timestamp: 2.hours.ago.to_i) } + let!(:point2) { create(:point, user: user, track: track2, timestamp: 1.hour.ago.to_i) } + + before do + # Mock tracks as connected + allow(detector).to receive(:find_boundary_track_candidates).and_return([[track1, track2]]) + + # Mock merge failure + allow(detector).to receive(:create_track_from_points).and_return(nil) + end + + it 'returns 0 and logs warning' do + expect(detector.resolve_cross_chunk_tracks).to eq(0) + end + + it 'does not delete original tracks' do + detector.resolve_cross_chunk_tracks + expect(Track.exists?(track1.id)).to be true + expect(Track.exists?(track2.id)).to be true + end + end + end + + describe 'private methods' do + describe '#find_connected_tracks' do + let!(:base_track) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.5.hours.ago) } + let!(:connected_track) { create(:track, user: user, start_at: 1.hour.ago, end_at: 30.minutes.ago) } + let!(:distant_track) { create(:track, user: user, start_at: 5.hours.ago, end_at: 4.hours.ago) } + + let!(:base_point_end) { create(:point, user: user, track: base_track, timestamp: 1.5.hours.ago.to_i) } + let!(:connected_point_start) { create(:point, user: user, track: connected_track, timestamp: 1.hour.ago.to_i) } + let!(:distant_point) { create(:point, user: user, track: distant_track, timestamp: 4.hours.ago.to_i) } + + let(:all_tracks) { [base_track, connected_track, distant_track] } + + before do + # Mock distance for spatially connected tracks + allow(base_point_end).to receive(:distance_to_geocoder).with(connected_point_start, :m).and_return(100) + allow(base_point_end).to receive(:distance_to_geocoder).with(distant_point, :m).and_return(2000) + end + + it 'finds temporally and spatially connected tracks' do + connected = detector.send(:find_connected_tracks, base_track, all_tracks) + expect(connected).to include(connected_track) + expect(connected).not_to include(distant_track) + end + + it 'excludes the base track itself' do + connected = detector.send(:find_connected_tracks, base_track, all_tracks) + expect(connected).not_to include(base_track) + end + + it 'handles tracks with no points' do + track_no_points = create(:track, user: user, start_at: 1.hour.ago, end_at: 30.minutes.ago) + all_tracks_with_empty = all_tracks + [track_no_points] + + expect { + detector.send(:find_connected_tracks, base_track, all_tracks_with_empty) + }.not_to raise_error + end + end + + describe '#tracks_spatially_connected?' do + let!(:track1) { create(:track, user: user) } + let!(:track2) { create(:track, user: user) } + + context 'when tracks have no points' do + it 'returns false' do + result = detector.send(:tracks_spatially_connected?, track1, track2) + expect(result).to be false + end + end + + context 'when tracks have points' do + let!(:track1_start) { create(:point, user: user, track: track1, timestamp: 2.hours.ago.to_i) } + let!(:track1_end) { create(:point, user: user, track: track1, timestamp: 1.5.hours.ago.to_i) } + let!(:track2_start) { create(:point, user: user, track: track2, timestamp: 1.hour.ago.to_i) } + let!(:track2_end) { create(:point, user: user, track: track2, timestamp: 30.minutes.ago.to_i) } + + context 'when track1 end connects to track2 start' do + before do + # Mock specific point-to-point distance calls that the method will make + allow(track1_end).to receive(:distance_to_geocoder).with(track2_start, :m).and_return(100) # Connected + allow(track2_end).to receive(:distance_to_geocoder).with(track1_start, :m).and_return(1000) # Not connected + allow(track1_start).to receive(:distance_to_geocoder).with(track2_start, :m).and_return(1000) # Not connected + allow(track1_end).to receive(:distance_to_geocoder).with(track2_end, :m).and_return(1000) # Not connected + end + + it 'returns true' do + result = detector.send(:tracks_spatially_connected?, track1, track2) + expect(result).to be true + end + end + + context 'when tracks are not spatially connected' do + before do + allow_any_instance_of(Point).to receive(:distance_to_geocoder).and_return(1000) # All points far apart + end + + it 'returns false' do + result = detector.send(:tracks_spatially_connected?, track1, track2) + expect(result).to be false + end + end + end + end + + describe '#points_are_close?' do + let(:point1) { create(:point, user: user) } + let(:point2) { create(:point, user: user) } + let(:threshold) { 500 } + + it 'returns true when points are within threshold' do + allow(point1).to receive(:distance_to_geocoder).with(point2, :m).and_return(300) + + result = detector.send(:points_are_close?, point1, point2, threshold) + expect(result).to be true + end + + it 'returns false when points exceed threshold' do + allow(point1).to receive(:distance_to_geocoder).with(point2, :m).and_return(700) + + result = detector.send(:points_are_close?, point1, point2, threshold) + expect(result).to be false + end + + it 'returns false when points are nil' do + result = detector.send(:points_are_close?, nil, point2, threshold) + expect(result).to be false + + result = detector.send(:points_are_close?, point1, nil, threshold) + expect(result).to be false + end + end + + describe '#valid_boundary_group?' do + let!(:track1) { create(:track, user: user, start_at: 3.hours.ago, end_at: 2.hours.ago) } + let!(:track2) { create(:track, user: user, start_at: 1.5.hours.ago, end_at: 1.hour.ago) } + let!(:track3) { create(:track, user: user, start_at: 45.minutes.ago, end_at: 30.minutes.ago) } + + it 'returns false for single track groups' do + result = detector.send(:valid_boundary_group?, [track1]) + expect(result).to be false + end + + it 'returns true for valid sequential groups' do + result = detector.send(:valid_boundary_group?, [track1, track2, track3]) + expect(result).to be true + end + + it 'returns false for groups with large time gaps' do + distant_track = create(:track, user: user, start_at: 10.hours.ago, end_at: 9.hours.ago) + result = detector.send(:valid_boundary_group?, [distant_track, track1]) + expect(result).to be false + end + end + + describe '#merge_boundary_tracks' do + let!(:track1) { create(:track, user: user, start_at: 2.hours.ago, end_at: 1.5.hours.ago) } + let!(:track2) { create(:track, user: user, start_at: 1.hour.ago, end_at: 30.minutes.ago) } + + let!(:point1) { create(:point, user: user, track: track1, timestamp: 2.hours.ago.to_i) } + let!(:point2) { create(:point, user: user, track: track1, timestamp: 1.5.hours.ago.to_i) } + let!(:point3) { create(:point, user: user, track: track2, timestamp: 1.hour.ago.to_i) } + let!(:point4) { create(:point, user: user, track: track2, timestamp: 30.minutes.ago.to_i) } + + it 'returns false for groups with less than 2 tracks' do + result = detector.send(:merge_boundary_tracks, [track1]) + expect(result).to be false + end + + it 'successfully merges tracks with sufficient points' do + # Mock successful track creation + merged_track = create(:track, user: user) + allow(detector).to receive(:create_track_from_points).and_return(merged_track) + + result = detector.send(:merge_boundary_tracks, [track1, track2]) + expect(result).to be true + end + + it 'collects all points from all tracks' do + # Capture the points passed to create_track_from_points + captured_points = nil + allow(detector).to receive(:create_track_from_points) do |points, _distance| + captured_points = points + create(:track, user: user) + end + + detector.send(:merge_boundary_tracks, [track1, track2]) + + expect(captured_points).to contain_exactly(point1, point2, point3, point4) + end + + it 'sorts points by timestamp' do + # Create points out of order + point_early = create(:point, user: user, track: track2, timestamp: 3.hours.ago.to_i) + + captured_points = nil + allow(detector).to receive(:create_track_from_points) do |points, _distance| + captured_points = points + create(:track, user: user) + end + + detector.send(:merge_boundary_tracks, [track1, track2]) + + timestamps = captured_points.map(&:timestamp) + expect(timestamps).to eq(timestamps.sort) + end + + it 'handles insufficient points gracefully' do + # Remove points to have less than 2 total + Point.where(track: [track1, track2]).limit(3).destroy_all + + result = detector.send(:merge_boundary_tracks, [track1, track2]) + expect(result).to be false + end + end + + describe 'user settings integration' do + before do + # Reset the memoized values for each test + detector.instance_variable_set(:@distance_threshold_meters, nil) + detector.instance_variable_set(:@time_threshold_minutes, nil) + end + + it 'uses cached distance threshold' do + # Call multiple times to test memoization + detector.send(:distance_threshold_meters) + detector.send(:distance_threshold_meters) + + expect(safe_settings).to have_received(:meters_between_routes).once + end + + it 'uses cached time threshold' do + # Call multiple times to test memoization + detector.send(:time_threshold_minutes) + detector.send(:time_threshold_minutes) + + expect(safe_settings).to have_received(:minutes_between_routes).once + end + end + end +end diff --git a/spec/services/tracks/generator_spec.rb b/spec/services/tracks/generator_spec.rb deleted file mode 100644 index 6f352b86..00000000 --- a/spec/services/tracks/generator_spec.rb +++ /dev/null @@ -1,260 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Tracks::Generator do - let(:user) { create(:user) } - let(:safe_settings) { user.safe_settings } - - before do - allow(user).to receive(:safe_settings).and_return(safe_settings) - end - - describe '#call' do - context 'with bulk mode' do - let(:generator) { described_class.new(user, mode: :bulk) } - - context 'with sufficient points' do - let!(:points) { create_points_around(user: user, count: 5, base_lat: 20.0) } - - it 'generates tracks from all points' do - expect { generator.call }.to change(Track, :count).by(1) - end - - it 'cleans existing tracks' do - existing_track = create(:track, user: user) - generator.call - expect(Track.exists?(existing_track.id)).to be false - end - - it 'associates points with created tracks' do - generator.call - expect(points.map(&:reload).map(&:track)).to all(be_present) - end - - it 'properly handles point associations when cleaning existing tracks' do - # Create existing tracks with associated points - existing_track = create(:track, user: user) - existing_points = create_list(:point, 3, user: user, track: existing_track) - - # Verify points are associated - expect(existing_points.map(&:reload).map(&:track_id)).to all(eq(existing_track.id)) - - # Run generator which should clean existing tracks and create new ones - generator.call - - # Verify the old track is deleted - expect(Track.exists?(existing_track.id)).to be false - - # Verify the points are no longer associated with the deleted track - expect(existing_points.map(&:reload).map(&:track_id)).to all(be_nil) - end - end - - context 'with insufficient points' do - let!(:points) { create_points_around(user: user, count: 1, base_lat: 20.0) } - - it 'does not create tracks' do - expect { generator.call }.not_to change(Track, :count) - end - end - - context 'with time range' do - let!(:old_points) { create_points_around(user: user, count: 3, base_lat: 20.0, timestamp: 2.days.ago.to_i) } - let!(:new_points) { create_points_around(user: user, count: 3, base_lat: 21.0, timestamp: 1.day.ago.to_i) } - - it 'only processes points within range' do - generator = described_class.new( - user, - start_at: 1.day.ago.beginning_of_day, - end_at: 1.day.ago.end_of_day, - mode: :bulk - ) - - generator.call - track = Track.last - expect(track.points.count).to eq(3) - end - end - end - - context 'with incremental mode' do - let(:generator) { described_class.new(user, mode: :incremental) } - - context 'with untracked points' do - let!(:points) { create_points_around(user: user, count: 3, base_lat: 22.0, track_id: nil) } - - it 'processes untracked points' do - expect { generator.call }.to change(Track, :count).by(1) - end - - it 'associates points with created tracks' do - generator.call - expect(points.map(&:reload).map(&:track)).to all(be_present) - end - end - - context 'with end_at specified' do - let!(:early_points) { create_points_around(user: user, count: 2, base_lat: 23.0, timestamp: 2.hours.ago.to_i) } - let!(:late_points) { create_points_around(user: user, count: 2, base_lat: 24.0, timestamp: 1.hour.ago.to_i) } - - it 'only processes points up to end_at' do - generator = described_class.new(user, end_at: 1.5.hours.ago, mode: :incremental) - generator.call - - expect(Track.count).to eq(1) - expect(Track.first.points.count).to eq(2) - end - end - - context 'without existing tracks' do - let!(:points) { create_points_around(user: user, count: 3, base_lat: 25.0) } - - it 'does not clean existing tracks' do - existing_track = create(:track, user: user) - generator.call - expect(Track.exists?(existing_track.id)).to be true - end - end - end - - context 'with daily mode' do - let(:today) { Date.current } - let(:generator) { described_class.new(user, start_at: today, mode: :daily) } - - let!(:today_points) { create_points_around(user: user, count: 3, base_lat: 26.0, timestamp: today.beginning_of_day.to_i) } - let!(:yesterday_points) { create_points_around(user: user, count: 3, base_lat: 27.0, timestamp: 1.day.ago.to_i) } - - it 'only processes points from specified day' do - generator.call - track = Track.last - expect(track.points.count).to eq(3) - end - - it 'cleans existing tracks for the day' do - existing_track = create(:track, user: user, start_at: today.beginning_of_day) - generator.call - expect(Track.exists?(existing_track.id)).to be false - end - - it 'properly handles point associations when cleaning daily tracks' do - # Create existing tracks with associated points for today - existing_track = create(:track, user: user, start_at: today.beginning_of_day) - existing_points = create_list(:point, 3, user: user, track: existing_track) - - # Verify points are associated - expect(existing_points.map(&:reload).map(&:track_id)).to all(eq(existing_track.id)) - - # Run generator which should clean existing tracks for the day and create new ones - generator.call - - # Verify the old track is deleted - expect(Track.exists?(existing_track.id)).to be false - - # Verify the points are no longer associated with the deleted track - expect(existing_points.map(&:reload).map(&:track_id)).to all(be_nil) - end - end - - context 'with empty points' do - let(:generator) { described_class.new(user, mode: :bulk) } - - it 'does not create tracks' do - expect { generator.call }.not_to change(Track, :count) - end - end - - context 'with threshold configuration' do - let(:generator) { described_class.new(user, mode: :bulk) } - - before do - allow(safe_settings).to receive(:meters_between_routes).and_return(1000) - allow(safe_settings).to receive(:minutes_between_routes).and_return(90) - end - - it 'uses configured thresholds' do - expect(generator.send(:distance_threshold_meters)).to eq(1000) - expect(generator.send(:time_threshold_minutes)).to eq(90) - end - end - - context 'with invalid mode' do - it 'raises argument error' do - expect do - described_class.new(user, mode: :invalid).call - end.to raise_error(ArgumentError, /Unknown mode/) - end - end - end - - describe 'segmentation behavior' do - let(:generator) { described_class.new(user, mode: :bulk) } - - context 'with points exceeding time threshold' do - let!(:points) do - [ - create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 90.minutes.ago.to_i), - create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 60.minutes.ago.to_i), - # Gap exceeds threshold 👇👇👇 - create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: 10.minutes.ago.to_i), - create_points_around(user: user, count: 1, base_lat: 29.0, timestamp: Time.current.to_i) - ] - end - - before do - allow(safe_settings).to receive(:minutes_between_routes).and_return(45) - end - - it 'creates separate tracks for segments' do - expect { generator.call }.to change(Track, :count).by(2) - end - end - - context 'with points exceeding distance threshold' do - let!(:points) do - [ - create_points_around(user: user, count: 2, base_lat: 29.0, timestamp: 20.minutes.ago.to_i), - create_points_around(user: user, count: 2, base_lat: 29.0, timestamp: 15.minutes.ago.to_i), - # Large distance jump 👇👇👇 - create_points_around(user: user, count: 2, base_lat: 28.0, timestamp: 10.minutes.ago.to_i), - create_points_around(user: user, count: 1, base_lat: 28.0, timestamp: Time.current.to_i) - ] - end - - before do - allow(safe_settings).to receive(:meters_between_routes).and_return(200) - end - - it 'creates separate tracks for segments' do - expect { generator.call }.to change(Track, :count).by(2) - end - end - end - - describe 'deterministic behavior' do - let!(:points) { create_points_around(user: user, count: 10, base_lat: 28.0) } - - it 'produces same results for bulk and incremental modes' do - # Generate tracks in bulk mode - bulk_generator = described_class.new(user, mode: :bulk) - bulk_generator.call - bulk_tracks = user.tracks.order(:start_at).to_a - - # Clear tracks and generate incrementally - user.tracks.destroy_all - incremental_generator = described_class.new(user, mode: :incremental) - incremental_generator.call - incremental_tracks = user.tracks.order(:start_at).to_a - - # Should have same number of tracks - expect(incremental_tracks.size).to eq(bulk_tracks.size) - - # Should have same track boundaries (allowing for small timing differences) - bulk_tracks.zip(incremental_tracks).each do |bulk_track, incremental_track| - expect(incremental_track.start_at).to be_within(1.second).of(bulk_track.start_at) - expect(incremental_track.end_at).to be_within(1.second).of(bulk_track.end_at) - expect(incremental_track.distance).to be_within(10).of(bulk_track.distance) - end - end - end -end diff --git a/spec/services/tracks/incremental_processor_spec.rb b/spec/services/tracks/incremental_processor_spec.rb deleted file mode 100644 index 165af52d..00000000 --- a/spec/services/tracks/incremental_processor_spec.rb +++ /dev/null @@ -1,249 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -RSpec.describe Tracks::IncrementalProcessor do - let(:user) { create(:user) } - let(:safe_settings) { user.safe_settings } - - before do - allow(user).to receive(:safe_settings).and_return(safe_settings) - allow(safe_settings).to receive(:minutes_between_routes).and_return(30) - allow(safe_settings).to receive(:meters_between_routes).and_return(500) - end - - describe '#call' do - context 'with imported points' do - let(:imported_point) { create(:point, user: user, import: create(:import)) } - let(:processor) { described_class.new(user, imported_point) } - - it 'does not process imported points' do - expect(Tracks::CreateJob).not_to receive(:perform_later) - - processor.call - end - end - - context 'with first point for user' do - let(:new_point) { create(:point, user: user) } - let(:processor) { described_class.new(user, new_point) } - - it 'processes first point' do - expect(Tracks::CreateJob).to receive(:perform_later) - .with(user.id, start_at: nil, end_at: nil, mode: :incremental) - processor.call - end - end - - context 'with thresholds exceeded' do - let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) } - let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) } - let(:processor) { described_class.new(user, new_point) } - - before do - # Create previous point first - previous_point - end - - it 'processes when time threshold exceeded' do - expect(Tracks::CreateJob).to receive(:perform_later) - .with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental) - processor.call - end - end - - context 'with existing tracks' do - let(:existing_track) { create(:track, user: user, end_at: 2.hours.ago) } - let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) } - let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) } - let(:processor) { described_class.new(user, new_point) } - - before do - existing_track - previous_point - end - - it 'uses existing track end time as start_at' do - expect(Tracks::CreateJob).to receive(:perform_later) - .with(user.id, start_at: existing_track.end_at, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental) - processor.call - end - end - - context 'with distance threshold exceeded' do - let(:previous_point) do - create(:point, user: user, timestamp: 10.minutes.ago.to_i, lonlat: 'POINT(0 0)') - end - let(:new_point) do - create(:point, user: user, timestamp: Time.current.to_i, lonlat: 'POINT(1 1)') - end - let(:processor) { described_class.new(user, new_point) } - - before do - # Create previous point first - previous_point - # Mock distance calculation to exceed threshold - allow_any_instance_of(Point).to receive(:distance_to).and_return(1.0) # 1 km = 1000m - end - - it 'processes when distance threshold exceeded' do - expect(Tracks::CreateJob).to receive(:perform_later) - .with(user.id, start_at: nil, end_at: Time.zone.at(previous_point.timestamp), mode: :incremental) - processor.call - end - end - - context 'with thresholds not exceeded' do - let(:previous_point) { create(:point, user: user, timestamp: 10.minutes.ago.to_i) } - let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) } - let(:processor) { described_class.new(user, new_point) } - - before do - # Create previous point first - previous_point - # Mock distance to be within threshold - allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m - end - - it 'does not process when thresholds not exceeded' do - expect(Tracks::CreateJob).not_to receive(:perform_later) - processor.call - end - end - end - - describe '#should_process?' do - let(:processor) { described_class.new(user, new_point) } - - context 'with imported point' do - let(:new_point) { create(:point, user: user, import: create(:import)) } - - it 'returns false' do - expect(processor.send(:should_process?)).to be false - end - end - - context 'with first point for user' do - let(:new_point) { create(:point, user: user) } - - it 'returns true' do - expect(processor.send(:should_process?)).to be true - end - end - - context 'with thresholds exceeded' do - let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) } - let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) } - - before do - previous_point # Create previous point - end - - it 'returns true when time threshold exceeded' do - expect(processor.send(:should_process?)).to be true - end - end - - context 'with thresholds not exceeded' do - let(:previous_point) { create(:point, user: user, timestamp: 10.minutes.ago.to_i) } - let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) } - - before do - previous_point # Create previous point - allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m - end - - it 'returns false when thresholds not exceeded' do - expect(processor.send(:should_process?)).to be false - end - end - end - - describe '#exceeds_thresholds?' do - let(:processor) { described_class.new(user, new_point) } - let(:previous_point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) } - let(:new_point) { create(:point, user: user, timestamp: Time.current.to_i) } - - context 'with time threshold exceeded' do - before do - allow(safe_settings).to receive(:minutes_between_routes).and_return(30) - end - - it 'returns true' do - result = processor.send(:exceeds_thresholds?, previous_point, new_point) - expect(result).to be true - end - end - - context 'with distance threshold exceeded' do - before do - allow(safe_settings).to receive(:minutes_between_routes).and_return(120) # 2 hours - allow(safe_settings).to receive(:meters_between_routes).and_return(400) - allow_any_instance_of(Point).to receive(:distance_to).and_return(0.5) # 500m - end - - it 'returns true' do - result = processor.send(:exceeds_thresholds?, previous_point, new_point) - expect(result).to be true - end - end - - context 'with neither threshold exceeded' do - before do - allow(safe_settings).to receive(:minutes_between_routes).and_return(120) # 2 hours - allow(safe_settings).to receive(:meters_between_routes).and_return(600) - allow_any_instance_of(Point).to receive(:distance_to).and_return(0.1) # 100m - end - - it 'returns false' do - result = processor.send(:exceeds_thresholds?, previous_point, new_point) - expect(result).to be false - end - end - end - - describe '#time_difference_minutes' do - let(:processor) { described_class.new(user, new_point) } - let(:point1) { create(:point, user: user, timestamp: 1.hour.ago.to_i) } - let(:point2) { create(:point, user: user, timestamp: Time.current.to_i) } - let(:new_point) { point2 } - - it 'calculates time difference in minutes' do - result = processor.send(:time_difference_minutes, point1, point2) - expect(result).to be_within(1).of(60) # Approximately 60 minutes - end - end - - describe '#distance_difference_meters' do - let(:processor) { described_class.new(user, new_point) } - let(:point1) { create(:point, user: user) } - let(:point2) { create(:point, user: user) } - let(:new_point) { point2 } - - before do - allow(point1).to receive(:distance_to).with(point2).and_return(1.5) # 1.5 km - end - - it 'calculates distance difference in meters' do - result = processor.send(:distance_difference_meters, point1, point2) - expect(result).to eq(1500) # 1.5 km = 1500 m - end - end - - describe 'threshold configuration' do - let(:processor) { described_class.new(user, create(:point, user: user)) } - - before do - allow(safe_settings).to receive(:minutes_between_routes).and_return(45) - allow(safe_settings).to receive(:meters_between_routes).and_return(750) - end - - it 'uses configured time threshold' do - expect(processor.send(:time_threshold_minutes)).to eq(45) - end - - it 'uses configured distance threshold' do - expect(processor.send(:distance_threshold_meters)).to eq(750) - end - end -end diff --git a/spec/services/tracks/parallel_generator_spec.rb b/spec/services/tracks/parallel_generator_spec.rb new file mode 100644 index 00000000..eebe107b --- /dev/null +++ b/spec/services/tracks/parallel_generator_spec.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::ParallelGenerator do + let(:user) { create(:user) } + let(:generator) { described_class.new(user, **options) } + let(:options) { {} } + + before do + Rails.cache.clear + # Stub user settings + allow(user.safe_settings).to receive(:minutes_between_routes).and_return(30) + allow(user.safe_settings).to receive(:meters_between_routes).and_return(500) + end + + describe '#initialize' do + it 'sets default values' do + expect(generator.user).to eq(user) + expect(generator.start_at).to be_nil + expect(generator.end_at).to be_nil + expect(generator.mode).to eq(:bulk) + expect(generator.chunk_size).to eq(1.day) + end + + it 'accepts custom options' do + start_time = 1.week.ago + end_time = Time.current + + custom_generator = described_class.new( + user, + start_at: start_time, + end_at: end_time, + mode: :daily, + chunk_size: 2.days + ) + + expect(custom_generator.start_at).to eq(start_time) + expect(custom_generator.end_at).to eq(end_time) + expect(custom_generator.mode).to eq(:daily) + expect(custom_generator.chunk_size).to eq(2.days) + end + + it 'converts mode to symbol' do + generator = described_class.new(user, mode: 'incremental') + expect(generator.mode).to eq(:incremental) + end + end + + describe '#call' do + let!(:point1) { create(:point, user: user, timestamp: 2.days.ago.to_i) } + let!(:point2) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + context 'with successful execution' do + it 'returns a session manager' do + result = generator.call + + expect(result).to be_a(Tracks::SessionManager) + expect(result.user_id).to eq(user.id) + expect(result.session_exists?).to be true + end + + it 'creates session with correct metadata' do + result = generator.call + + session_data = result.get_session_data + expect(session_data['metadata']['mode']).to eq('bulk') + expect(session_data['metadata']['chunk_size']).to eq('1 day') + expect(session_data['metadata']['user_settings']['time_threshold_minutes']).to eq(30) + expect(session_data['metadata']['user_settings']['distance_threshold_meters']).to eq(500) + end + + it 'marks session as started with chunk count' do + result = generator.call + + session_data = result.get_session_data + expect(session_data['status']).to eq('processing') + expect(session_data['total_chunks']).to be > 0 + expect(session_data['started_at']).to be_present + end + + it 'enqueues time chunk processor jobs' do + expect do + generator.call + end.to have_enqueued_job(Tracks::TimeChunkProcessorJob).at_least(:once) + end + + it 'enqueues boundary resolver job with delay' do + expect do + generator.call + end.to have_enqueued_job(Tracks::BoundaryResolverJob).at(be >= 5.minutes.from_now) + end + + it 'logs the operation' do + allow(Rails.logger).to receive(:info) # Allow any log messages + expect(Rails.logger).to receive(:info).with(/Started parallel track generation/).at_least(:once) + generator.call + end + end + + context 'when no time chunks are generated' do + let(:user_no_points) { create(:user) } + let(:generator) { described_class.new(user_no_points) } + + it 'returns 0 (no session created)' do + result = generator.call + expect(result).to eq(0) + end + + it 'does not enqueue any jobs' do + expect do + generator.call + end.not_to have_enqueued_job + end + end + + context 'with different modes' do + let!(:track1) { create(:track, user: user, start_at: 2.days.ago) } + let!(:track2) { create(:track, user: user, start_at: 1.day.ago) } + + context 'bulk mode' do + let(:options) { { mode: :bulk } } + + it 'cleans existing tracks' do + expect(user.tracks.count).to eq(2) + + generator.call + + expect(user.tracks.count).to eq(0) + end + end + + context 'daily mode' do + let(:options) { { mode: :daily, start_at: 1.day.ago.beginning_of_day } } + + it 'preserves existing tracks' do + expect(user.tracks.count).to eq(2) + + generator.call + + # Daily mode should preserve all existing tracks + remaining_tracks = user.tracks.count + expect(remaining_tracks).to eq(2) + end + end + + context 'incremental mode' do + let(:options) { { mode: :incremental } } + + it 'does not clean existing tracks' do + expect(user.tracks.count).to eq(2) + + generator.call + + expect(user.tracks.count).to eq(2) + end + end + end + + context 'with time range specified' do + let(:start_time) { 3.days.ago } + let(:end_time) { 1.day.ago } + let(:options) { { start_at: start_time, end_at: end_time, mode: :bulk } } + let!(:track_in_range) { create(:track, user: user, start_at: 2.days.ago, end_at: 2.days.ago + 1.hour) } + let!(:track_out_of_range) { create(:track, user: user, start_at: 1.week.ago, end_at: 1.week.ago + 1.hour) } + + it 'only cleans tracks within the specified range' do + expect(user.tracks.count).to eq(2) + + generator.call + + # Should only clean the track within the time range + remaining_tracks = user.tracks + expect(remaining_tracks.count).to eq(1) + expect(remaining_tracks.first).to eq(track_out_of_range) + end + + it 'includes time range in session metadata' do + result = generator.call + + session_data = result.get_session_data + expect(session_data['metadata']['start_at']).to eq(start_time.iso8601) + expect(session_data['metadata']['end_at']).to eq(end_time.iso8601) + end + end + + context 'job coordination' do + it 'calculates estimated delay based on chunk count' do + # Create more points to generate more chunks + 10.times do |i| + create(:point, user: user, timestamp: (10 - i).days.ago.to_i) + end + + expect do + generator.call + end.to have_enqueued_job(Tracks::BoundaryResolverJob) + .with(user.id, kind_of(String)) + end + + it 'ensures minimum delay for boundary resolver' do + # Even with few chunks, should have minimum delay + expect do + generator.call + end.to have_enqueued_job(Tracks::BoundaryResolverJob) + .at(be >= 5.minutes.from_now) + end + end + + context 'user settings integration' do + let(:mock_settings) { double('SafeSettings') } + + before do + # Create a proper mock and stub user.safe_settings to return it + allow(mock_settings).to receive(:minutes_between_routes).and_return(60) + allow(mock_settings).to receive(:meters_between_routes).and_return(1000) + allow(user).to receive(:safe_settings).and_return(mock_settings) + end + + it 'includes user settings in session metadata' do + result = generator.call + + session_data = result.get_session_data + user_settings = session_data['metadata']['user_settings'] + expect(user_settings['time_threshold_minutes']).to eq(60) + expect(user_settings['distance_threshold_meters']).to eq(1000) + end + + it 'caches user settings' do + # Call the methods multiple times + generator.send(:time_threshold_minutes) + generator.send(:time_threshold_minutes) + generator.send(:distance_threshold_meters) + generator.send(:distance_threshold_meters) + + # Should only call safe_settings once per method due to memoization + expect(mock_settings).to have_received(:minutes_between_routes).once + expect(mock_settings).to have_received(:meters_between_routes).once + end + end + end + + describe 'private methods' do + describe '#generate_time_chunks' do + let!(:point1) { create(:point, user: user, timestamp: 2.days.ago.to_i) } + let!(:point2) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + it 'creates TimeChunker with correct parameters' do + expect(Tracks::TimeChunker).to receive(:new) + .with(user, start_at: nil, end_at: nil, chunk_size: 1.day) + .and_call_original + + generator.send(:generate_time_chunks) + end + + it 'returns chunks from TimeChunker' do + chunks = generator.send(:generate_time_chunks) + expect(chunks).to be_an(Array) + expect(chunks).not_to be_empty + end + end + + describe '#enqueue_chunk_jobs' do + let(:session_id) { 'test-session' } + let(:chunks) do + [ + { chunk_id: 'chunk1', start_timestamp: 1.day.ago.to_i }, + { chunk_id: 'chunk2', start_timestamp: 2.days.ago.to_i } + ] + end + + it 'enqueues job for each chunk' do + expect do + generator.send(:enqueue_chunk_jobs, session_id, chunks) + end.to have_enqueued_job(Tracks::TimeChunkProcessorJob) + .exactly(2).times + end + + it 'passes correct parameters to each job' do + expect(Tracks::TimeChunkProcessorJob).to receive(:perform_later) + .with(user.id, session_id, chunks[0]) + expect(Tracks::TimeChunkProcessorJob).to receive(:perform_later) + .with(user.id, session_id, chunks[1]) + + generator.send(:enqueue_chunk_jobs, session_id, chunks) + end + end + + describe '#enqueue_boundary_resolver' do + let(:session_id) { 'test-session' } + + it 'enqueues boundary resolver with estimated delay' do + expect do + generator.send(:enqueue_boundary_resolver, session_id, 5) + end.to have_enqueued_job(Tracks::BoundaryResolverJob) + .with(user.id, session_id) + .at(be >= 2.minutes.from_now) + end + + it 'uses minimum delay for small chunk counts' do + expect do + generator.send(:enqueue_boundary_resolver, session_id, 1) + end.to have_enqueued_job(Tracks::BoundaryResolverJob) + .at(be >= 5.minutes.from_now) + end + + it 'scales delay with chunk count' do + expect do + generator.send(:enqueue_boundary_resolver, session_id, 20) + end.to have_enqueued_job(Tracks::BoundaryResolverJob) + .at(be >= 10.minutes.from_now) + end + end + + describe 'time range handling' do + let(:start_time) { 3.days.ago } + let(:end_time) { 1.day.ago } + let(:generator) { described_class.new(user, start_at: start_time, end_at: end_time) } + + describe '#time_range_defined?' do + it 'returns true when start_at or end_at is defined' do + expect(generator.send(:time_range_defined?)).to be true + end + + it 'returns false when neither is defined' do + generator = described_class.new(user) + expect(generator.send(:time_range_defined?)).to be false + end + end + + describe '#time_range' do + it 'creates proper time range when both defined' do + range = generator.send(:time_range) + expect(range.begin).to eq(Time.zone.at(start_time.to_i)) + expect(range.end).to eq(Time.zone.at(end_time.to_i)) + end + + it 'creates open-ended range when only start defined' do + generator = described_class.new(user, start_at: start_time) + range = generator.send(:time_range) + expect(range.begin).to eq(Time.zone.at(start_time.to_i)) + expect(range.end).to be_nil + end + + it 'creates range with open beginning when only end defined' do + generator = described_class.new(user, end_at: end_time) + range = generator.send(:time_range) + expect(range.begin).to be_nil + expect(range.end).to eq(Time.zone.at(end_time.to_i)) + end + end + + describe '#daily_time_range' do + let(:day) { 2.days.ago.to_date } + let(:generator) { described_class.new(user, start_at: day) } + + it 'creates range for entire day' do + range = generator.send(:daily_time_range) + expect(range.begin).to eq(day.beginning_of_day.to_i) + expect(range.end).to eq(day.end_of_day.to_i) + end + + it 'uses current date when start_at not provided' do + generator = described_class.new(user) + range = generator.send(:daily_time_range) + expect(range.begin).to eq(Date.current.beginning_of_day.to_i) + expect(range.end).to eq(Date.current.end_of_day.to_i) + end + end + end + end +end diff --git a/spec/services/tracks/session_manager_spec.rb b/spec/services/tracks/session_manager_spec.rb new file mode 100644 index 00000000..aefc55f7 --- /dev/null +++ b/spec/services/tracks/session_manager_spec.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::SessionManager do + let(:user_id) { 123 } + let(:session_id) { 'test-session-id' } + let(:manager) { described_class.new(user_id, session_id) } + + before do + Rails.cache.clear + end + + describe '#initialize' do + it 'creates manager with provided user_id and session_id' do + expect(manager.user_id).to eq(user_id) + expect(manager.session_id).to eq(session_id) + end + + it 'generates UUID session_id when not provided' do + manager = described_class.new(user_id) + expect(manager.session_id).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + end + end + + describe '#create_session' do + let(:metadata) { { mode: 'bulk', chunk_size: '1.day' } } + + it 'creates a new session with default values' do + result = manager.create_session(metadata) + + expect(result).to eq(manager) + expect(manager.session_exists?).to be true + + session_data = manager.get_session_data + expect(session_data['status']).to eq('pending') + expect(session_data['total_chunks']).to eq(0) + expect(session_data['completed_chunks']).to eq(0) + expect(session_data['tracks_created']).to eq(0) + expect(session_data['metadata']).to eq(metadata.deep_stringify_keys) + expect(session_data['started_at']).to be_present + expect(session_data['completed_at']).to be_nil + expect(session_data['error_message']).to be_nil + end + + it 'sets TTL on the cache entry' do + manager.create_session(metadata) + + # Check that the key exists and will expire + expect(Rails.cache.exist?(manager.send(:cache_key))).to be true + end + end + + describe '#get_session_data' do + it 'returns nil when session does not exist' do + expect(manager.get_session_data).to be_nil + end + + it 'returns session data when session exists' do + metadata = { test: 'data' } + manager.create_session(metadata) + + data = manager.get_session_data + expect(data).to be_a(Hash) + expect(data['metadata']).to eq(metadata.deep_stringify_keys) + end + end + + describe '#session_exists?' do + it 'returns false when session does not exist' do + expect(manager.session_exists?).to be false + end + + it 'returns true when session exists' do + manager.create_session + expect(manager.session_exists?).to be true + end + end + + describe '#update_session' do + before do + manager.create_session + end + + it 'updates existing session data' do + updates = { status: 'processing', total_chunks: 5 } + result = manager.update_session(updates) + + expect(result).to be true + + data = manager.get_session_data + expect(data['status']).to eq('processing') + expect(data['total_chunks']).to eq(5) + end + + it 'returns false when session does not exist' do + manager.cleanup_session + result = manager.update_session({ status: 'processing' }) + + expect(result).to be false + end + + it 'preserves existing data when updating' do + original_metadata = { mode: 'bulk' } + manager.cleanup_session + manager.create_session(original_metadata) + + manager.update_session({ status: 'processing' }) + + data = manager.get_session_data + expect(data['metadata']).to eq(original_metadata.stringify_keys) + expect(data['status']).to eq('processing') + end + end + + describe '#mark_started' do + before do + manager.create_session + end + + it 'marks session as processing with total chunks' do + result = manager.mark_started(10) + + expect(result).to be true + + data = manager.get_session_data + expect(data['status']).to eq('processing') + expect(data['total_chunks']).to eq(10) + expect(data['started_at']).to be_present + end + end + + describe '#increment_completed_chunks' do + before do + manager.create_session + manager.mark_started(5) + end + + it 'increments completed chunks counter' do + expect do + manager.increment_completed_chunks + end.to change { + manager.get_session_data['completed_chunks'] + }.from(0).to(1) + end + + it 'returns false when session does not exist' do + manager.cleanup_session + expect(manager.increment_completed_chunks).to be false + end + end + + describe '#increment_tracks_created' do + before do + manager.create_session + end + + it 'increments tracks created counter by 1 by default' do + expect do + manager.increment_tracks_created + end.to change { + manager.get_session_data['tracks_created'] + }.from(0).to(1) + end + + it 'increments tracks created counter by specified amount' do + expect do + manager.increment_tracks_created(5) + end.to change { + manager.get_session_data['tracks_created'] + }.from(0).to(5) + end + + it 'returns false when session does not exist' do + manager.cleanup_session + expect(manager.increment_tracks_created).to be false + end + end + + describe '#mark_completed' do + before do + manager.create_session + end + + it 'marks session as completed with timestamp' do + result = manager.mark_completed + + expect(result).to be true + + data = manager.get_session_data + expect(data['status']).to eq('completed') + expect(data['completed_at']).to be_present + end + end + + describe '#mark_failed' do + before do + manager.create_session + end + + it 'marks session as failed with error message and timestamp' do + error_message = 'Something went wrong' + + result = manager.mark_failed(error_message) + + expect(result).to be true + + data = manager.get_session_data + expect(data['status']).to eq('failed') + expect(data['error_message']).to eq(error_message) + expect(data['completed_at']).to be_present + end + end + + describe '#all_chunks_completed?' do + before do + manager.create_session + manager.mark_started(3) + end + + it 'returns false when not all chunks are completed' do + manager.increment_completed_chunks + expect(manager.all_chunks_completed?).to be false + end + + it 'returns true when all chunks are completed' do + 3.times { manager.increment_completed_chunks } + expect(manager.all_chunks_completed?).to be true + end + + it 'returns true when completed chunks exceed total (edge case)' do + 4.times { manager.increment_completed_chunks } + expect(manager.all_chunks_completed?).to be true + end + + it 'returns false when session does not exist' do + manager.cleanup_session + expect(manager.all_chunks_completed?).to be false + end + end + + describe '#cleanup_session' do + before do + manager.create_session + end + + it 'removes session from cache' do + expect(manager.session_exists?).to be true + + manager.cleanup_session + + expect(manager.session_exists?).to be false + end + end + + describe '.create_for_user' do + let(:metadata) { { mode: 'daily' } } + + it 'creates and returns a session manager' do + result = described_class.create_for_user(user_id, metadata) + + expect(result).to be_a(described_class) + expect(result.user_id).to eq(user_id) + expect(result.session_exists?).to be true + + data = result.get_session_data + expect(data['metadata']).to eq(metadata.deep_stringify_keys) + end + end + + describe 'cache key scoping' do + it 'uses user-scoped cache keys' do + expected_key = "track_generation:user:#{user_id}:session:#{session_id}" + actual_key = manager.send(:cache_key) + + expect(actual_key).to eq(expected_key) + end + + it 'prevents cross-user session access' do + manager.create_session + other_manager = described_class.new(999, session_id) + + expect(manager.session_exists?).to be true + expect(other_manager.session_exists?).to be false + end + end +end diff --git a/spec/services/tracks/time_chunker_spec.rb b/spec/services/tracks/time_chunker_spec.rb new file mode 100644 index 00000000..5a38a052 --- /dev/null +++ b/spec/services/tracks/time_chunker_spec.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Tracks::TimeChunker do + let(:user) { create(:user) } + let(:chunker) { described_class.new(user, **options) } + let(:options) { {} } + + describe '#initialize' do + it 'sets default values' do + expect(chunker.user).to eq(user) + expect(chunker.start_at).to be_nil + expect(chunker.end_at).to be_nil + expect(chunker.chunk_size).to eq(1.day) + expect(chunker.buffer_size).to eq(6.hours) + end + + it 'accepts custom options' do + start_time = 1.week.ago + end_time = Time.current + + custom_chunker = described_class.new( + user, + start_at: start_time, + end_at: end_time, + chunk_size: 2.days, + buffer_size: 2.hours + ) + + expect(custom_chunker.start_at).to eq(start_time) + expect(custom_chunker.end_at).to eq(end_time) + expect(custom_chunker.chunk_size).to eq(2.days) + expect(custom_chunker.buffer_size).to eq(2.hours) + end + end + + describe '#call' do + context 'when user has no points' do + it 'returns empty array' do + expect(chunker.call).to eq([]) + end + end + + context 'when start_at is after end_at' do + let(:options) { { start_at: Time.current, end_at: 1.day.ago } } + + it 'returns empty array' do + expect(chunker.call).to eq([]) + end + end + + context 'with user points' do + let!(:point1) { create(:point, user: user, timestamp: 3.days.ago.to_i) } + let!(:point2) { create(:point, user: user, timestamp: 2.days.ago.to_i) } + let!(:point3) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + context 'with both start_at and end_at provided' do + let(:start_time) { 3.days.ago } + let(:end_time) { 1.day.ago } + let(:options) { { start_at: start_time, end_at: end_time } } + + it 'creates chunks for the specified range' do + chunks = chunker.call + + expect(chunks).not_to be_empty + expect(chunks.first[:start_time]).to be >= start_time + expect(chunks.last[:end_time]).to be <= end_time + end + + it 'creates chunks with buffer zones' do + chunks = chunker.call + + chunk = chunks.first + # Buffer zones should be at or beyond chunk boundaries (may be constrained by global boundaries) + expect(chunk[:buffer_start_time]).to be <= chunk[:start_time] + expect(chunk[:buffer_end_time]).to be >= chunk[:end_time] + + # Verify buffer timestamps are consistent + expect(chunk[:buffer_start_timestamp]).to eq(chunk[:buffer_start_time].to_i) + expect(chunk[:buffer_end_timestamp]).to eq(chunk[:buffer_end_time].to_i) + end + + it 'includes required chunk data structure' do + chunks = chunker.call + + chunk = chunks.first + expect(chunk).to include( + :chunk_id, + :start_timestamp, + :end_timestamp, + :buffer_start_timestamp, + :buffer_end_timestamp, + :start_time, + :end_time, + :buffer_start_time, + :buffer_end_time + ) + + expect(chunk[:chunk_id]).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + end + end + + context 'with only start_at provided' do + let(:start_time) { 2.days.ago } + let(:options) { { start_at: start_time } } + + it 'creates chunks from start_at to current time' do + # Capture current time before running to avoid precision issues + end_time_before = Time.current + chunks = chunker.call + end_time_after = Time.current + + expect(chunks).not_to be_empty + expect(chunks.first[:start_time]).to be >= start_time + # Allow for some time drift during test execution + expect(chunks.last[:end_time]).to be_between(end_time_before, end_time_after + 1.second) + end + end + + context 'with only end_at provided' do + let(:options) { { end_at: 1.day.ago } } + + it 'creates chunks from first point to end_at' do + chunks = chunker.call + + expect(chunks).not_to be_empty + expect(chunks.first[:start_time]).to be >= Time.at(point1.timestamp) + expect(chunks.last[:end_time]).to be <= 1.day.ago + end + end + + context 'with no time range provided' do + it 'creates chunks for full user point range' do + chunks = chunker.call + + expect(chunks).not_to be_empty + expect(chunks.first[:start_time]).to be >= Time.at(point1.timestamp) + expect(chunks.last[:end_time]).to be <= Time.at(point3.timestamp) + end + end + + context 'with custom chunk size' do + let(:options) { { chunk_size: 12.hours, start_at: 2.days.ago, end_at: Time.current } } + + it 'creates smaller chunks' do + chunks = chunker.call + + # Should create more chunks with smaller chunk size + expect(chunks.size).to be > 2 + + # Each chunk should be approximately 12 hours + chunk = chunks.first + duration = chunk[:end_time] - chunk[:start_time] + expect(duration).to be <= 12.hours + end + end + + context 'with custom buffer size' do + let(:options) { { buffer_size: 1.hour, start_at: 2.days.ago, end_at: Time.current } } + + it 'creates chunks with smaller buffer zones' do + chunks = chunker.call + + chunk = chunks.first + buffer_start_diff = chunk[:start_time] - chunk[:buffer_start_time] + buffer_end_diff = chunk[:buffer_end_time] - chunk[:end_time] + + expect(buffer_start_diff).to be <= 1.hour + expect(buffer_end_diff).to be <= 1.hour + end + end + end + + context 'buffer zone boundary handling' do + let!(:point1) { create(:point, user: user, timestamp: 1.week.ago.to_i) } + let!(:point2) { create(:point, user: user, timestamp: Time.current.to_i) } + let(:options) { { start_at: 3.days.ago, end_at: Time.current } } + + it 'does not extend buffers beyond global boundaries' do + chunks = chunker.call + + chunk = chunks.first + expect(chunk[:buffer_start_time]).to be >= 3.days.ago + expect(chunk[:buffer_end_time]).to be <= Time.current + end + end + + context 'chunk filtering based on points' do + let(:options) { { start_at: 1.week.ago, end_at: Time.current } } + + context 'when chunk has no points in buffer range' do + # Create points only at the very end of the range + let!(:point) { create(:point, user: user, timestamp: 1.hour.ago.to_i) } + + it 'filters out empty chunks' do + chunks = chunker.call + + # Should only include chunks that actually have points + expect(chunks).not_to be_empty + chunks.each do |chunk| + # Verify each chunk has points in its buffer range + points_exist = user.points + .where(timestamp: chunk[:buffer_start_timestamp]..chunk[:buffer_end_timestamp]) + .exists? + expect(points_exist).to be true + end + end + end + end + + context 'timestamp consistency' do + let!(:point) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + let(:options) { { start_at: 2.days.ago, end_at: Time.current } } + + it 'maintains timestamp consistency between Time objects and integers' do + chunks = chunker.call + + chunk = chunks.first + expect(chunk[:start_timestamp]).to eq(chunk[:start_time].to_i) + expect(chunk[:end_timestamp]).to eq(chunk[:end_time].to_i) + expect(chunk[:buffer_start_timestamp]).to eq(chunk[:buffer_start_time].to_i) + expect(chunk[:buffer_end_timestamp]).to eq(chunk[:buffer_end_time].to_i) + end + end + + context 'edge cases' do + context 'when start_at equals end_at' do + let(:time_point) { 1.day.ago } + let(:options) { { start_at: time_point, end_at: time_point } } + + it 'returns empty array' do + expect(chunker.call).to eq([]) + end + end + + context 'when user has only one point' do + let!(:point) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + it 'creates appropriate chunks' do + chunks = chunker.call + + # With only one point, start and end times are the same, so no chunks are created + # This is expected behavior as there's no time range to chunk + expect(chunks).to be_empty + end + end + + context 'when time range is very small' do + let(:base_time) { 1.day.ago } + let(:options) { { start_at: base_time, end_at: base_time + 1.hour } } + let!(:point) { create(:point, user: user, timestamp: base_time.to_i) } + + it 'creates at least one chunk' do + chunks = chunker.call + + expect(chunks.size).to eq(1) + expect(chunks.first[:start_time]).to eq(base_time) + expect(chunks.first[:end_time]).to eq(base_time + 1.hour) + end + end + end + end + + describe 'private methods' do + describe '#determine_time_range' do + let!(:point1) { create(:point, user: user, timestamp: 3.days.ago.to_i) } + let!(:point2) { create(:point, user: user, timestamp: 1.day.ago.to_i) } + + it 'handles all time range scenarios correctly' do + test_start_time = 2.days.ago + test_end_time = Time.current + + # Both provided + chunker_both = described_class.new(user, start_at: test_start_time, end_at: test_end_time) + result_both = chunker_both.send(:determine_time_range) + expect(result_both[0]).to be_within(1.second).of(test_start_time.to_time) + expect(result_both[1]).to be_within(1.second).of(test_end_time.to_time) + + # Only start provided + chunker_start = described_class.new(user, start_at: test_start_time) + result_start = chunker_start.send(:determine_time_range) + expect(result_start[0]).to be_within(1.second).of(test_start_time.to_time) + expect(result_start[1]).to be_within(1.second).of(Time.current) + + # Only end provided + chunker_end = described_class.new(user, end_at: test_end_time) + result_end = chunker_end.send(:determine_time_range) + expect(result_end[0]).to eq(Time.at(point1.timestamp)) + expect(result_end[1]).to be_within(1.second).of(test_end_time.to_time) + + # Neither provided + chunker_neither = described_class.new(user) + result_neither = chunker_neither.send(:determine_time_range) + expect(result_neither[0]).to eq(Time.at(point1.timestamp)) + expect(result_neither[1]).to eq(Time.at(point2.timestamp)) + end + + context 'when user has no points and end_at is provided' do + let(:user_no_points) { create(:user) } + let(:chunker_no_points) { described_class.new(user_no_points, end_at: Time.current) } + + it 'returns nil' do + expect(chunker_no_points.send(:determine_time_range)).to be_nil + end + end + end + end +end diff --git a/spec/services/users/export_data_spec.rb b/spec/services/users/export_data_spec.rb index cc603d75..1d668ece 100644 --- a/spec/services/users/export_data_spec.rb +++ b/spec/services/users/export_data_spec.rb @@ -45,7 +45,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) @@ -57,7 +57,7 @@ RSpec.describe Users::ExportData, type: :service do allow(export_record).to receive_message_chain(:file, :attach) # Mock Zip file creation - allow(Zip::File).to receive(:open).with(zip_file_path, Zip::File::CREATE).and_yield(zip_file_double) + allow(Zip::File).to receive(:open).with(zip_file_path, create: true).and_yield(zip_file_double) allow(zip_file_double).to receive(:default_compression=) allow(zip_file_double).to receive(:default_compression_level=) allow(zip_file_double).to receive(:add) @@ -108,7 +108,7 @@ RSpec.describe Users::ExportData, type: :service do end it 'creates a zip file with proper compression settings' do - expect(Zip::File).to receive(:open).with(zip_file_path, Zip::File::CREATE) + expect(Zip::File).to receive(:open).with(zip_file_path, create: true) expect(Zip).to receive(:default_compression).and_return(-1) # Mock original compression expect(Zip).to receive(:default_compression=).with(Zip::Entry::DEFLATED) expect(Zip).to receive(:default_compression=).with(-1) # Restoration @@ -187,7 +187,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) @@ -267,7 +267,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) @@ -374,7 +374,7 @@ RSpec.describe Users::ExportData, type: :service do allow(user).to receive(:trips).and_return(double(count: 8)) allow(user).to receive(:stats).and_return(double(count: 24)) allow(user).to receive(:notifications).and_return(double(count: 10)) - allow(user).to receive(:tracked_points).and_return(double(count: 15000)) + allow(user).to receive(:points_count).and_return(15000) allow(user).to receive(:visits).and_return(double(count: 45)) allow(user).to receive(:places).and_return(double(count: 20)) allow(Rails.logger).to receive(:info) diff --git a/spec/services/users/export_import_integration_spec.rb b/spec/services/users/export_import_integration_spec.rb index ed959048..2be18ee7 100644 --- a/spec/services/users/export_import_integration_spec.rb +++ b/spec/services/users/export_import_integration_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' RSpec.describe 'Users Export-Import Integration', type: :service do let(:original_user) { create(:user, email: 'original@example.com') } let(:target_user) { create(:user, email: 'target@example.com') } - let(:temp_archive_path) { Rails.root.join('tmp', 'test_export.zip') } + let(:temp_archive_path) { Rails.root.join('tmp/test_export.zip') } after do File.delete(temp_archive_path) if File.exist?(temp_archive_path) @@ -40,17 +40,12 @@ RSpec.describe 'Users Export-Import Integration', type: :service do Rails.logger.level = original_log_level end - puts "Import stats: #{import_stats.inspect}" - user_notifications_count = original_user.notifications.where.not( title: ['Data import completed', 'Data import failed', 'Export completed', 'Export failed'] ).count target_counts = calculate_user_entity_counts(target_user) - puts "Original counts: #{original_counts.inspect}" - puts "Target counts: #{target_counts.inspect}" - expect(target_counts[:areas]).to eq(original_counts[:areas]) expect(target_counts[:imports]).to eq(original_counts[:imports]) expect(target_counts[:exports]).to eq(original_counts[:exports]) @@ -184,18 +179,22 @@ RSpec.describe 'Users Export-Import Integration', type: :service do import_stats = import_service.import # Verify all entities were imported correctly - expect(import_stats[:places_created]).to eq(original_places_count), + expect(import_stats[:places_created]).to \ + eq(original_places_count), "Expected #{original_places_count} places to be created, got #{import_stats[:places_created]}" - expect(import_stats[:visits_created]).to eq(original_visits_count), + expect(import_stats[:visits_created]).to \ + eq(original_visits_count), "Expected #{original_visits_count} visits to be created, got #{import_stats[:visits_created]}" # Verify the imported user has access to all their data imported_places_count = import_user.places.distinct.count imported_visits_count = import_user.visits.count - expect(imported_places_count).to eq(original_places_count), + expect(imported_places_count).to \ + eq(original_places_count), "Expected user to have access to #{original_places_count} places, got #{imported_places_count}" - expect(imported_visits_count).to eq(original_visits_count), + expect(imported_visits_count).to \ + eq(original_visits_count), "Expected user to have #{original_visits_count} visits, got #{imported_visits_count}" # Verify specific visits have their place associations @@ -205,7 +204,7 @@ RSpec.describe 'Users Export-Import Integration', type: :service do # Verify place names are preserved place_names = visits_with_places.map { |v| v.place.name }.sort - expect(place_names).to eq(['Gym', 'Home', 'Office']) + expect(place_names).to eq(%w[Gym Home Office]) # Cleanup temp_export_file.unlink @@ -216,12 +215,13 @@ RSpec.describe 'Users Export-Import Integration', type: :service do private def create_full_user_dataset(user) - user.update!(settings: { - 'distance_unit' => 'km', - 'timezone' => 'America/New_York', - 'immich_url' => 'https://immich.example.com', - 'immich_api_key' => 'test-api-key' - }) + user.update!(settings: + { + 'distance_unit' => 'km', + 'timezone' => 'America/New_York', + 'immich_url' => 'https://immich.example.com', + 'immich_api_key' => 'test-api-key' + }) usa = create(:country, name: 'United States', iso_a2: 'US', iso_a3: 'USA') canada = create(:country, name: 'Canada', iso_a2: 'CA', iso_a3: 'CAN') @@ -271,37 +271,32 @@ RSpec.describe 'Users Export-Import Integration', type: :service do visit3 = create(:visit, user: user, place: nil, name: 'Unknown Location') create_list(:point, 5, - user: user, - import: import1, - country: usa, - visit: visit1, - latitude: 40.7589, - longitude: -73.9851 - ) + user: user, + import: import1, + country: usa, + visit: visit1, + latitude: 40.7589, + longitude: -73.9851) create_list(:point, 3, - user: user, - import: import2, - country: canada, - visit: visit2, - latitude: 40.7128, - longitude: -74.0060 - ) + user: user, + import: import2, + country: canada, + visit: visit2, + latitude: 40.7128, + longitude: -74.0060) create_list(:point, 2, - user: user, - import: nil, - country: nil, - visit: nil - ) + user: user, + import: nil, + country: nil, + visit: nil) create_list(:point, 2, - user: user, - import: import1, - country: usa, - visit: visit3 - ) - + user: user, + import: import1, + country: usa, + visit: visit3) end def calculate_user_entity_counts(user) @@ -312,41 +307,43 @@ RSpec.describe 'Users Export-Import Integration', type: :service do trips: user.trips.count, stats: user.stats.count, notifications: user.notifications.count, - points: user.tracked_points.count, + points: user.points.count, visits: user.visits.count, places: user.places.count } end def verify_relationships_preserved(original_user, target_user) - original_points_with_imports = original_user.tracked_points.where.not(import_id: nil).count - target_points_with_imports = target_user.tracked_points.where.not(import_id: nil).count + original_points_with_imports = original_user.points.where.not(import_id: nil).count + target_points_with_imports = target_user.points.where.not(import_id: nil).count expect(target_points_with_imports).to eq(original_points_with_imports) - original_points_with_countries = original_user.tracked_points.where.not(country_id: nil).count - target_points_with_countries = target_user.tracked_points.where.not(country_id: nil).count + original_points_with_countries = original_user.points.where.not(country_id: nil).count + target_points_with_countries = target_user.points.where.not(country_id: nil).count expect(target_points_with_countries).to eq(original_points_with_countries) - original_points_with_visits = original_user.tracked_points.where.not(visit_id: nil).count - target_points_with_visits = target_user.tracked_points.where.not(visit_id: nil).count + original_points_with_visits = original_user.points.where.not(visit_id: nil).count + target_points_with_visits = target_user.points.where.not(visit_id: nil).count expect(target_points_with_visits).to eq(original_points_with_visits) original_visits_with_places = original_user.visits.where.not(place_id: nil).count target_visits_with_places = target_user.visits.where.not(place_id: nil).count expect(target_visits_with_places).to eq(original_visits_with_places) - original_office_points = original_user.tracked_points.where( + original_office_points = original_user.points.where( latitude: 40.7589, longitude: -73.9851 ).first - target_office_points = target_user.tracked_points.where( + target_office_points = target_user.points.where( latitude: 40.7589, longitude: -73.9851 ).first - if original_office_points && target_office_points - expect(target_office_points.import.name).to eq(original_office_points.import.name) if original_office_points.import - expect(target_office_points.country.name).to eq(original_office_points.country.name) if original_office_points.country - expect(target_office_points.visit.name).to eq(original_office_points.visit.name) if original_office_points.visit + return unless original_office_points && target_office_points + + expect(target_office_points.import.name).to eq(original_office_points.import.name) if original_office_points.import + if original_office_points.country + expect(target_office_points.country.name).to eq(original_office_points.country.name) end + expect(target_office_points.visit.name).to eq(original_office_points.visit.name) if original_office_points.visit end def verify_settings_preserved(original_user, target_user) @@ -375,9 +372,9 @@ RSpec.describe 'Users Export-Import Integration', type: :service do original_export = original_user.exports.find_by(name: 'Q1 2024 Export') target_export = target_user.exports.find_by(name: 'Q1 2024 Export') - if original_export&.file&.attached? - expect(target_export).to be_present - expect(target_export.file).to be_attached - end + return unless original_export&.file&.attached? + + expect(target_export).to be_present + expect(target_export.file).to be_attached end end diff --git a/spec/services/users/import_data/imports_spec.rb b/spec/services/users/import_data/imports_spec.rb index f9ef66e9..9485bb95 100644 --- a/spec/services/users/import_data/imports_spec.rb +++ b/spec/services/users/import_data/imports_spec.rb @@ -221,7 +221,7 @@ RSpec.describe Users::ImportData::Imports, type: :service do created_imports = user.imports.pluck(:name, :source) expect(created_imports).to contain_exactly( ['Valid Import', 'owntracks'], - ['Missing Source Import', 'google_semantic_history'] + ['Missing Source Import', nil] ) end diff --git a/spec/services/users/import_data/places_streaming_spec.rb b/spec/services/users/import_data/places_streaming_spec.rb new file mode 100644 index 00000000..e476d443 --- /dev/null +++ b/spec/services/users/import_data/places_streaming_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::ImportData::Places do + let(:user) { create(:user) } + let(:logger) { instance_double(Logger, info: nil, debug: nil, error: nil) } + let(:service) { described_class.new(user, nil, logger: logger) } + + describe '#add / #finalize' do + it 'creates places in batches and tracks total created' do + 2.times do |index| + service.add( + 'name' => "Place #{index}", + 'latitude' => 10.0 + index, + 'longitude' => 20.0 + index + ) + end + + expect { service.finalize }.to change(Place, :count).by(2) + expect { expect(service.finalize).to eq(2) }.not_to change(Place, :count) + end + + it 'flushes automatically when the buffer reaches the batch size' do + stub_const('Users::ImportData::Places::BATCH_SIZE', 2) + + logger_double = instance_double(Logger) + allow(logger_double).to receive(:info) + allow(logger_double).to receive(:debug) + allow(logger_double).to receive(:error) + + buffered_service = described_class.new(user, nil, batch_size: 2, logger: logger_double) + + buffered_service.add('name' => 'First', 'latitude' => 1, 'longitude' => 2) + expect(Place.count).to eq(0) + + buffered_service.add('name' => 'Second', 'latitude' => 3, 'longitude' => 4) + expect(Place.count).to eq(2) + + expect(buffered_service.finalize).to eq(2) + expect { buffered_service.finalize }.not_to change(Place, :count) + end + + it 'skips invalid records and logs debug messages' do + allow(logger).to receive(:debug) + + service.add('name' => 'Valid', 'latitude' => 1, 'longitude' => 2) + service.add('name' => 'Missing coords') + + expect(service.finalize).to eq(1) + expect(logger).to have_received(:debug).with(/Skipping place with missing required data/) + end + end +end diff --git a/spec/services/users/import_data/points_spec.rb b/spec/services/users/import_data/points_spec.rb index cfb81c28..aa23e316 100644 --- a/spec/services/users/import_data/points_spec.rb +++ b/spec/services/users/import_data/points_spec.rb @@ -35,13 +35,13 @@ RSpec.describe Users::ImportData::Points, type: :service do it 'assigns the correct country association' do service.call - point = user.tracked_points.last + point = user.points.last expect(point.country).to eq(country) end it 'excludes the string country field from attributes' do service.call - point = user.tracked_points.last + point = user.points.last # The country association should be set, not the string attribute expect(point.read_attribute(:country)).to be_nil expect(point.country).to eq(country) @@ -68,7 +68,7 @@ RSpec.describe Users::ImportData::Points, type: :service do it 'does not create country and leaves country_id nil' do expect { service.call }.not_to change(Country, :count) - point = user.tracked_points.last + point = user.points.last expect(point.country_id).to be_nil expect(point.city).to eq('Berlin') end @@ -126,10 +126,10 @@ RSpec.describe Users::ImportData::Points, type: :service do it 'imports valid points and reconstructs lonlat when needed' do expect(service.call).to eq(2) # Two valid points (original + reconstructed) - expect(user.tracked_points.count).to eq(2) + expect(user.points.count).to eq(2) # Check that lonlat was reconstructed properly - munich_point = user.tracked_points.find_by(city: 'Munich') + munich_point = user.points.find_by(city: 'Munich') expect(munich_point).to be_present expect(munich_point.lonlat.to_s).to match(/POINT\s*\(11\.582\s+48\.1351\)/) end diff --git a/spec/services/users/import_data_spec.rb b/spec/services/users/import_data_spec.rb index 1fcf9cfd..2a660724 100644 --- a/spec/services/users/import_data_spec.rb +++ b/spec/services/users/import_data_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require 'rails_helper' +require 'tmpdir' +require 'oj' RSpec.describe Users::ImportData, type: :service do let(:user) { create(:user) } @@ -12,122 +14,59 @@ RSpec.describe Users::ImportData, type: :service do allow(Time).to receive(:current).and_return(Time.zone.at(1234567890)) allow(FileUtils).to receive(:mkdir_p) allow(FileUtils).to receive(:rm_rf) - allow(File).to receive(:directory?).and_return(true) + allow_any_instance_of(Pathname).to receive(:exist?).and_return(true) end describe '#import' do - let(:sample_data) do - { - 'counts' => { - 'areas' => 2, - 'places' => 3, - 'imports' => 1, - 'exports' => 1, - 'trips' => 2, - 'stats' => 1, - 'notifications' => 2, - 'visits' => 4, - 'points' => 1000 - }, - 'settings' => { 'theme' => 'dark' }, - 'areas' => [{ 'name' => 'Home', 'latitude' => '40.7128', 'longitude' => '-74.0060' }], - 'places' => [{ 'name' => 'Office', 'latitude' => '40.7589', 'longitude' => '-73.9851' }], - 'imports' => [{ 'name' => 'test.json', 'source' => 'owntracks' }], - 'exports' => [{ 'name' => 'export.json', 'status' => 'completed' }], - 'trips' => [{ 'name' => 'Trip to NYC', 'distance' => 100.5 }], - 'stats' => [{ 'year' => 2024, 'month' => 1, 'distance' => 456.78 }], - 'notifications' => [{ 'title' => 'Test', 'content' => 'Test notification' }], - 'visits' => [{ 'name' => 'Work Visit', 'duration' => 3600 }], - 'points' => [{ 'latitude' => 40.7128, 'longitude' => -74.0060, 'timestamp' => 1234567890 }] - } - end + let(:notification_double) { instance_double(::Notifications::Create, call: true) } before do - # Mock ZIP file extraction - zipfile_mock = double('ZipFile') - allow(zipfile_mock).to receive(:each) - allow(Zip::File).to receive(:open).with(archive_path).and_yield(zipfile_mock) - - # Mock JSON loading and File operations - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true) - allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(sample_data.to_json) - - # Mock all import services - allow(Users::ImportData::Settings).to receive(:new).and_return(double(call: true)) - allow(Users::ImportData::Areas).to receive(:new).and_return(double(call: 2)) - allow(Users::ImportData::Places).to receive(:new).and_return(double(call: 3)) - allow(Users::ImportData::Imports).to receive(:new).and_return(double(call: [1, 5])) - allow(Users::ImportData::Exports).to receive(:new).and_return(double(call: [1, 2])) - allow(Users::ImportData::Trips).to receive(:new).and_return(double(call: 2)) - allow(Users::ImportData::Stats).to receive(:new).and_return(double(call: 1)) - allow(Users::ImportData::Notifications).to receive(:new).and_return(double(call: 2)) - allow(Users::ImportData::Visits).to receive(:new).and_return(double(call: 4)) - allow(Users::ImportData::Points).to receive(:new).and_return(double(call: 1000)) - - # Mock notifications - allow(::Notifications::Create).to receive(:new).and_return(double(call: true)) - - # Mock cleanup + allow(::Notifications::Create).to receive(:new).and_return(notification_double) allow(service).to receive(:cleanup_temporary_files) - allow_any_instance_of(Pathname).to receive(:exist?).and_return(true) end - context 'when import is successful' do - it 'creates import directory' do + context 'when import succeeds' do + before do + allow(service).to receive(:extract_archive) + allow(service).to receive(:process_archive_data) do + stats = service.instance_variable_get(:@import_stats) + stats[:settings_updated] = true + stats[:areas_created] = 2 + stats[:places_created] = 3 + stats[:imports_created] = 1 + stats[:exports_created] = 1 + stats[:trips_created] = 2 + stats[:stats_created] = 1 + stats[:notifications_created] = 2 + stats[:visits_created] = 4 + stats[:points_created] = 1000 + stats[:files_restored] = 7 + end + end + + it 'creates the import directory' do expect(FileUtils).to receive(:mkdir_p).with(import_directory) - service.import end - it 'extracts the archive' do - expect(Zip::File).to receive(:open).with(archive_path) - + it 'extracts the archive and processes data' do + expect(service).to receive(:extract_archive).ordered + expect(service).to receive(:process_archive_data).ordered service.import end - it 'loads JSON data from extracted files' do - expect(File).to receive(:exist?).with(import_directory.join('data.json')) - expect(File).to receive(:read).with(import_directory.join('data.json')) - - service.import - end - - it 'calls all import services in correct order' do - expect(Users::ImportData::Settings).to receive(:new).with(user, sample_data['settings']).ordered - expect(Users::ImportData::Areas).to receive(:new).with(user, sample_data['areas']).ordered - expect(Users::ImportData::Places).to receive(:new).with(user, sample_data['places']).ordered - expect(Users::ImportData::Imports).to receive(:new).with(user, sample_data['imports'], import_directory.join('files')).ordered - expect(Users::ImportData::Exports).to receive(:new).with(user, sample_data['exports'], import_directory.join('files')).ordered - expect(Users::ImportData::Trips).to receive(:new).with(user, sample_data['trips']).ordered - expect(Users::ImportData::Stats).to receive(:new).with(user, sample_data['stats']).ordered - expect(Users::ImportData::Notifications).to receive(:new).with(user, sample_data['notifications']).ordered - expect(Users::ImportData::Visits).to receive(:new).with(user, sample_data['visits']).ordered - expect(Users::ImportData::Points).to receive(:new).with(user, sample_data['points']).ordered - - service.import - end - - it 'creates success notification with import stats' do + it 'creates a success notification with summary' do expect(::Notifications::Create).to receive(:new).with( user: user, title: 'Data import completed', - content: match(/1000 points.*4 visits.*3 places.*2 trips/), + content: include('1000 points, 4 visits, 3 places, 2 trips'), kind: :info ) - - service.import - end - - it 'cleans up temporary files' do - expect(service).to receive(:cleanup_temporary_files).with(import_directory) - service.import end it 'returns import statistics' do result = service.import - expect(result).to include( settings_updated: true, areas_created: 2, @@ -142,53 +81,18 @@ RSpec.describe Users::ImportData, type: :service do files_restored: 7 ) end - - it 'logs expected counts if available' do - allow(Rails.logger).to receive(:info) # Allow other log messages - expect(Rails.logger).to receive(:info).with(/Expected entity counts from export:/) - - service.import - end end - context 'when JSON file is missing' do + context 'when an error happens during processing' do + let(:error_message) { 'boom' } + before do - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(false) + allow(service).to receive(:extract_archive) + allow(service).to receive(:process_archive_data).and_raise(StandardError, error_message) allow(ExceptionReporter).to receive(:call) end - it 'raises an error' do - expect { service.import }.to raise_error(StandardError, 'Data file not found in archive: data.json') - end - end - - context 'when JSON is invalid' do - before do - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true) - allow(File).to receive(:read).with(import_directory.join('data.json')).and_return('invalid json') - allow(ExceptionReporter).to receive(:call) - end - - it 'raises a JSON parse error' do - expect { service.import }.to raise_error(StandardError, /Invalid JSON format in data file/) - end - end - - context 'when an error occurs during import' do - let(:error_message) { 'Something went wrong' } - - before do - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true) - allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(sample_data.to_json) - allow(Users::ImportData::Settings).to receive(:new).and_raise(StandardError, error_message) - allow(ExceptionReporter).to receive(:call) - allow(::Notifications::Create).to receive(:new).and_return(double(call: true)) - end - - it 'creates failure notification' do + it 'creates a failure notification and re-raises the error' do expect(::Notifications::Create).to receive(:new).with( user: user, title: 'Data import failed', @@ -198,100 +102,99 @@ RSpec.describe Users::ImportData, type: :service do expect { service.import }.to raise_error(StandardError, error_message) end - - it 'reports error via ExceptionReporter' do - expect(ExceptionReporter).to receive(:call).with( - an_instance_of(StandardError), - 'Data import failed' - ) - - expect { service.import }.to raise_error(StandardError, error_message) - end - - it 'still cleans up temporary files' do - expect(service).to receive(:cleanup_temporary_files) - - expect { service.import }.to raise_error(StandardError, error_message) - end - - it 're-raises the error' do - expect { service.import }.to raise_error(StandardError, error_message) - end - end - - context 'when data sections are missing' do - let(:minimal_data) { { 'settings' => { 'theme' => 'dark' } } } - - before do - # Reset JSON file mocking - allow(File).to receive(:exist?).and_return(false) - allow(File).to receive(:exist?).with(import_directory.join('data.json')).and_return(true) - allow(File).to receive(:read).with(import_directory.join('data.json')).and_return(minimal_data.to_json) - - # Only expect Settings to be called - allow(Users::ImportData::Settings).to receive(:new).and_return(double(call: true)) - allow(::Notifications::Create).to receive(:new).and_return(double(call: true)) - end - - it 'only imports available sections' do - expect(Users::ImportData::Settings).to receive(:new).with(user, minimal_data['settings']) - expect(Users::ImportData::Areas).not_to receive(:new) - expect(Users::ImportData::Places).not_to receive(:new) - - service.import - end end end - describe 'private methods' do - describe '#cleanup_temporary_files' do - context 'when directory exists' do - before do - allow(File).to receive(:directory?).and_return(true) - allow(Rails.logger).to receive(:info) - end + describe '#process_archive_data' do + let(:tmp_dir) { Pathname.new(Dir.mktmpdir) } + let(:json_path) { tmp_dir.join('data.json') } + let(:places_calls) { [] } + let(:visits_batches) { [] } + let(:points_ingested) { [] } + let(:points_importer) do + instance_double(Users::ImportData::Points, add: nil, finalize: 2) + end - it 'removes the directory' do - expect(FileUtils).to receive(:rm_rf).with(import_directory) + before do + payload = { + 'counts' => { 'places' => 2, 'visits' => 2, 'points' => 2 }, + 'settings' => { 'theme' => 'dark' }, + 'areas' => [], + 'imports' => [], + 'exports' => [], + 'trips' => [], + 'stats' => [], + 'notifications' => [], + 'places' => [ + { 'name' => 'Cafe', 'latitude' => 1.0, 'longitude' => 2.0 }, + { 'name' => 'Library', 'latitude' => 3.0, 'longitude' => 4.0 } + ], + 'visits' => [ + { + 'name' => 'Morning Coffee', + 'started_at' => '2025-01-01T09:00:00Z', + 'ended_at' => '2025-01-01T10:00:00Z' + }, + { + 'name' => 'Study Time', + 'started_at' => '2025-01-01T12:00:00Z', + 'ended_at' => '2025-01-01T14:00:00Z' + } + ], + 'points' => [ + { 'timestamp' => 1, 'lonlat' => 'POINT(2 1)' }, + { 'timestamp' => 2, 'lonlat' => 'POINT(4 3)' } + ] + } - service.send(:cleanup_temporary_files, import_directory) - end + File.write(json_path, Oj.dump(payload, mode: :compat)) - it 'logs the cleanup' do - expect(Rails.logger).to receive(:info).with("Cleaning up temporary import directory: #{import_directory}") + service.instance_variable_set(:@import_directory, tmp_dir) - service.send(:cleanup_temporary_files, import_directory) - end + allow(Users::ImportData::Settings).to receive(:new).and_return(double(call: true)) + allow(Users::ImportData::Areas).to receive(:new).and_return(double(call: 0)) + allow(Users::ImportData::Imports).to receive(:new).and_return(double(call: [0, 0])) + allow(Users::ImportData::Exports).to receive(:new).and_return(double(call: [0, 0])) + allow(Users::ImportData::Trips).to receive(:new).and_return(double(call: 0)) + allow(Users::ImportData::Stats).to receive(:new).and_return(double(call: 0)) + allow(Users::ImportData::Notifications).to receive(:new).and_return(double(call: 0)) + + allow(Users::ImportData::Places).to receive(:new) do |_, batch| + places_calls << batch + double(call: batch.size) end - context 'when cleanup fails' do - before do - allow(File).to receive(:directory?).and_return(true) - allow(FileUtils).to receive(:rm_rf).and_raise(StandardError, 'Permission denied') - allow(ExceptionReporter).to receive(:call) - end - - it 'reports error via ExceptionReporter but does not re-raise' do - expect(ExceptionReporter).to receive(:call).with( - an_instance_of(StandardError), - 'Failed to cleanup temporary files' - ) - - expect { service.send(:cleanup_temporary_files, import_directory) }.not_to raise_error - end + allow(Users::ImportData::Visits).to receive(:new) do |_, batch| + visits_batches << batch + double(call: batch.size) end - context 'when directory does not exist' do - before do - allow(File).to receive(:directory?).and_return(false) - end - - it 'does not attempt cleanup' do - expect(FileUtils).not_to receive(:rm_rf) - - service.send(:cleanup_temporary_files, import_directory) - end + allow(points_importer).to receive(:add) do |point| + points_ingested << point end + + allow(Users::ImportData::Points).to receive(:new) do |_, points_data, batch_size:| + expect(points_data).to be_nil + expect(batch_size).to eq(described_class::STREAM_BATCH_SIZE) + points_importer + end + end + + after do + FileUtils.remove_entry(tmp_dir) + end + + it 'streams sections and updates import stats' do + service.send(:process_archive_data) + + expect(places_calls.flatten.map { |place| place['name'] }).to contain_exactly('Cafe', 'Library') + expect(visits_batches.flatten.map { |visit| visit['name'] }).to contain_exactly('Morning Coffee', 'Study Time') + expect(points_ingested.map { |point| point['timestamp'] }).to eq([1, 2]) + + stats = service.instance_variable_get(:@import_stats) + expect(stats[:places_created]).to eq(2) + expect(stats[:visits_created]).to eq(2) + expect(stats[:points_created]).to eq(2) end end end diff --git a/spec/services/visits/create_spec.rb b/spec/services/visits/create_spec.rb new file mode 100644 index 00000000..37a5ec88 --- /dev/null +++ b/spec/services/visits/create_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Visits::Create do + let(:user) { create(:user) } + let(:valid_params) do + { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + end + + describe '#call' do + context 'when all parameters are valid' do + subject(:service) { described_class.new(user, valid_params) } + + it 'creates a visit successfully' do + expect { service.call }.to change { user.visits.count }.by(1) + expect(service.call).to be_truthy + expect(service.visit).to be_persisted + end + + it 'creates a visit with correct attributes' do + service.call + visit = service.visit + + expect(visit.name).to eq('Test Visit') + expect(visit.user).to eq(user) + expect(visit.status).to eq('confirmed') + expect(visit.started_at).to eq(DateTime.parse('2023-12-01T10:00:00Z')) + expect(visit.ended_at).to eq(DateTime.parse('2023-12-01T12:00:00Z')) + expect(visit.duration).to eq(120) # 2 hours in minutes + end + + it 'creates a place with correct coordinates' do + service.call + place = service.visit.place + + expect(place).to be_persisted + expect(place.name).to eq('Test Visit') + expect(place.latitude).to eq(52.52) + expect(place.longitude).to eq(13.405) + expect(place.source).to eq('manual') + end + end + + context 'when reusing existing place' do + let!(:existing_place) do + create(:place, + latitude: 52.52, + longitude: 13.405, + lonlat: 'POINT(13.405 52.52)') + end + let!(:existing_visit) { create(:visit, user: user, place: existing_place) } + + subject(:service) { described_class.new(user, valid_params) } + + it 'reuses the existing place' do + expect { service.call }.not_to change { Place.count } + expect(service.visit.place).to eq(existing_place) + end + + it 'creates a new visit with the existing place' do + expect { service.call }.to change { user.visits.count }.by(1) + expect(service.visit.place).to eq(existing_place) + end + end + + context 'when place creation fails' do + subject(:service) { described_class.new(user, valid_params) } + + before do + allow(Place).to receive(:create!).and_raise(ActiveRecord::RecordInvalid.new(Place.new)) + end + + it 'returns false' do + expect(service.call).to be(false) + end + + it 'calls ExceptionReporter' do + expect(ExceptionReporter).to receive(:call) + + service.call + end + + it 'does not create a visit' do + expect { service.call }.not_to change { Visit.count } + end + end + + context 'when visit creation fails' do + subject(:service) { described_class.new(user, valid_params) } + + before do + allow_any_instance_of(User).to receive_message_chain(:visits, :create!).and_raise(ActiveRecord::RecordInvalid.new(Visit.new)) + end + + it 'returns false' do + expect(service.call).to be(false) + end + + it 'calls ExceptionReporter' do + expect(ExceptionReporter).to receive(:call) + + service.call + end + end + + context 'edge cases' do + context 'when name is not provided but defaults are used' do + let(:params) { valid_params.merge(name: '') } + subject(:service) { described_class.new(user, params) } + + it 'returns false due to validation' do + expect(service.call).to be(false) + end + end + + context 'when coordinates are strings' do + let(:params) do + valid_params.merge( + latitude: '52.52', + longitude: '13.405' + ) + end + subject(:service) { described_class.new(user, params) } + + it 'converts them to floats and creates visit successfully' do + expect(service.call).to be_truthy + place = service.visit.place + expect(place.latitude).to eq(52.52) + expect(place.longitude).to eq(13.405) + end + end + + context 'when visit duration is very short' do + let(:params) do + valid_params.merge( + started_at: '2023-12-01T12:00:00Z', + ended_at: '2023-12-01T12:01:00Z' # 1 minute + ) + end + subject(:service) { described_class.new(user, params) } + + it 'creates visit with correct duration' do + service.call + expect(service.visit.duration).to eq(1) + end + end + + context 'when visit duration is very long' do + let(:params) do + valid_params.merge( + started_at: '2023-12-01T08:00:00Z', + ended_at: '2023-12-02T20:00:00Z' # 36 hours + ) + end + subject(:service) { described_class.new(user, params) } + + it 'creates visit with correct duration' do + service.call + expect(service.visit.duration).to eq(36 * 60) # 36 hours in minutes + end + end + + context 'when datetime-local input is provided without timezone' do + let(:params) do + valid_params.merge( + started_at: '2023-12-01T19:54', + ended_at: '2023-12-01T20:54' + ) + end + subject(:service) { described_class.new(user, params) } + + it 'parses the datetime in the application timezone' do + service.call + visit = service.visit + + expect(visit.started_at.hour).to eq(19) + expect(visit.started_at.min).to eq(54) + expect(visit.ended_at.hour).to eq(20) + expect(visit.ended_at.min).to eq(54) + end + + it 'calculates correct duration' do + service.call + expect(service.visit.duration).to eq(60) # 1 hour in minutes + end + end + end + end +end diff --git a/spec/services/visits/smart_detect_spec.rb b/spec/services/visits/smart_detect_spec.rb index 37ea7638..006770ae 100644 --- a/spec/services/visits/smart_detect_spec.rb +++ b/spec/services/visits/smart_detect_spec.rb @@ -27,7 +27,7 @@ RSpec.describe Visits::SmartDetect do let(:created_visits) { [instance_double(Visit)] } before do - allow(user).to receive_message_chain(:tracked_points, :not_visited, :order, :where).and_return(points) + allow(user).to receive_message_chain(:points, :not_visited, :order, :where).and_return(points) allow(Visits::Detector).to receive(:new).with(points).and_return(visit_detector) allow(Visits::Merger).to receive(:new).with(points).and_return(visit_merger) allow(Visits::Creator).to receive(:new).with(user).and_return(visit_creator) diff --git a/spec/swagger/api/v1/visits_controller_spec.rb b/spec/swagger/api/v1/visits_controller_spec.rb new file mode 100644 index 00000000..b83f6beb --- /dev/null +++ b/spec/swagger/api/v1/visits_controller_spec.rb @@ -0,0 +1,393 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +describe 'Visits API', type: :request do + let(:user) { create(:user) } + let(:api_key) { user.api_key } + let(:place) { create(:place) } + let(:test_visit) { create(:visit, user: user, place: place) } + + path '/api/v1/visits' do + get 'List visits' do + tags 'Visits' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :start_at, in: :query, type: :string, required: false, description: 'Start date (ISO 8601)' + parameter name: :end_at, in: :query, type: :string, required: false, description: 'End date (ISO 8601)' + parameter name: :selection, in: :query, type: :string, required: false, description: 'Set to "true" for area-based search' + parameter name: :sw_lat, in: :query, type: :number, required: false, description: 'Southwest latitude for area search' + parameter name: :sw_lng, in: :query, type: :number, required: false, description: 'Southwest longitude for area search' + parameter name: :ne_lat, in: :query, type: :number, required: false, description: 'Northeast latitude for area search' + parameter name: :ne_lng, in: :query, type: :number, required: false, description: 'Northeast longitude for area search' + + response '200', 'visits found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:start_at) { 1.week.ago.iso8601 } + let(:end_at) { Time.current.iso8601 } + + schema type: :array, + items: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string, enum: %w[suggested confirmed declined] }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer, description: 'Duration in minutes' }, + place: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number }, + city: { type: :string }, + country: { type: :string } + } + } + }, + required: %w[id name status started_at ended_at duration] + } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + run_test! + end + end + + post 'Create visit' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :visit, in: :body, schema: { + type: :object, + properties: { + visit: { + type: :object, + properties: { + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime } + }, + required: %w[name latitude longitude started_at ended_at] + } + } + } + + response '200', 'visit created' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit) do + { + visit: { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + schema type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer }, + place: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number } + } + } + } + + run_test! + end + + response '422', 'invalid request' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit) do + { + visit: { + name: '', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:visit) do + { + visit: { + name: 'Test Visit', + latitude: 52.52, + longitude: 13.405, + started_at: '2023-12-01T10:00:00Z', + ended_at: '2023-12-01T12:00:00Z' + } + } + end + + run_test! + end + end + end + + path '/api/v1/visits/{id}' do + patch 'Update visit' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :visit, in: :body, schema: { + type: :object, + properties: { + visit: { + type: :object, + properties: { + name: { type: :string }, + place_id: { type: :integer }, + status: { type: :string, enum: %w[suggested confirmed declined] } + } + } + } + } + + response '200', 'visit updated' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { test_visit.id } + let(:visit) { { visit: { name: 'Updated Visit' } } } + + schema type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer }, + place: { type: :object } + } + + run_test! + end + + response '404', 'visit not found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { 999999 } + let(:visit) { { visit: { name: 'Updated Visit' } } } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:id) { test_visit.id } + let(:visit) { { visit: { name: 'Updated Visit' } } } + + run_test! + end + end + + delete 'Delete visit' do + tags 'Visits' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + + response '204', 'visit deleted' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { test_visit.id } + + run_test! + end + + response '404', 'visit not found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { 999999 } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:id) { test_visit.id } + + run_test! + end + end + end + + path '/api/v1/visits/{id}/possible_places' do + get 'Get possible places for visit' do + tags 'Visits' + produces 'application/json' + parameter name: :id, in: :path, type: :integer, required: true, description: 'Visit ID' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + + response '200', 'possible places found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { test_visit.id } + + schema type: :array, + items: { + type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + latitude: { type: :number }, + longitude: { type: :number }, + city: { type: :string }, + country: { type: :string } + } + } + + run_test! + end + + response '404', 'visit not found' do + let(:Authorization) { "Bearer #{api_key}" } + let(:id) { 999999 } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:id) { test_visit.id } + + run_test! + end + end + end + + path '/api/v1/visits/merge' do + post 'Merge visits' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :merge_params, in: :body, schema: { + type: :object, + properties: { + visit_ids: { + type: :array, + items: { type: :integer }, + minItems: 2, + description: 'Array of visit IDs to merge (minimum 2)' + } + }, + required: %w[visit_ids] + } + + response '200', 'visits merged' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit1) { create(:visit, user: user) } + let(:visit2) { create(:visit, user: user) } + let(:merge_params) { { visit_ids: [visit1.id, visit2.id] } } + + schema type: :object, + properties: { + id: { type: :integer }, + name: { type: :string }, + status: { type: :string }, + started_at: { type: :string, format: :datetime }, + ended_at: { type: :string, format: :datetime }, + duration: { type: :integer }, + place: { type: :object } + } + + run_test! + end + + response '422', 'invalid request' do + let(:Authorization) { "Bearer #{api_key}" } + let(:merge_params) { { visit_ids: [test_visit.id] } } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:merge_params) { { visit_ids: [test_visit.id] } } + + run_test! + end + end + end + + path '/api/v1/visits/bulk_update' do + post 'Bulk update visits' do + tags 'Visits' + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, type: :string, required: true, description: 'Bearer token' + parameter name: :bulk_params, in: :body, schema: { + type: :object, + properties: { + visit_ids: { + type: :array, + items: { type: :integer }, + description: 'Array of visit IDs to update' + }, + status: { + type: :string, + enum: %w[suggested confirmed declined], + description: 'New status for the visits' + } + }, + required: %w[visit_ids status] + } + + response '200', 'visits updated' do + let(:Authorization) { "Bearer #{api_key}" } + let(:visit1) { create(:visit, user: user, status: 'suggested') } + let(:visit2) { create(:visit, user: user, status: 'suggested') } + let(:bulk_params) { { visit_ids: [visit1.id, visit2.id], status: 'confirmed' } } + + schema type: :object, + properties: { + message: { type: :string }, + updated_count: { type: :integer } + } + + run_test! + end + + response '422', 'invalid request' do + let(:Authorization) { "Bearer #{api_key}" } + let(:bulk_params) { { visit_ids: [test_visit.id], status: 'invalid_status' } } + + run_test! + end + + response '401', 'unauthorized' do + let(:Authorization) { 'Bearer invalid-token' } + let(:bulk_params) { { visit_ids: [test_visit.id], status: 'confirmed' } } + + run_test! + end + end + end +end \ No newline at end of file diff --git a/spec/system/map_interaction_spec.rb b/spec/system/map_interaction_spec.rb index 4d616a4e..43dc9e41 100644 --- a/spec/system/map_interaction_spec.rb +++ b/spec/system/map_interaction_spec.rb @@ -15,22 +15,20 @@ RSpec.describe 'Map Interaction', type: :system do # Create a series of points that form a route [ create(:point, user: user, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end - - describe 'Map page interaction' do context 'when user is signed in' do include_context 'authenticated map user' @@ -127,7 +125,7 @@ RSpec.describe 'Map Interaction', type: :system do # The calendar panel JavaScript interaction is complex and may not work # reliably in headless test environment, but the button should be functional - puts "Note: Calendar button is functional. Panel interaction may require manual testing." + puts 'Note: Calendar button is functional. Panel interaction may require manual testing.' end end @@ -207,28 +205,30 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end - context 'with miles distance unit' do - let(:user_with_miles) { create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') } + context 'with miles distance unit' do + let(:user_with_miles) do + create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') + end let!(:points_for_miles_user) do # Create a series of points that form a route for the miles user [ create(:point, user: user_with_miles, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user_with_miles, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user_with_miles, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user_with_miles, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end @@ -280,7 +280,7 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end @@ -288,22 +288,24 @@ RSpec.describe 'Map Interaction', type: :system do context 'polyline popup content' do context 'with km distance unit' do - let(:user_with_km) { create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123') } + let(:user_with_km) do + create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123') + end let!(:points_for_km_user) do # Create a series of points that form a route for the km user [ create(:point, user: user_with_km, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user_with_km, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user_with_km, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user_with_km, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end @@ -356,28 +358,30 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('km') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end context 'with miles distance unit' do - let(:user_with_miles) { create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') } + let(:user_with_miles) do + create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123') + end let!(:points_for_miles_user) do # Create a series of points that form a route for the miles user [ create(:point, user: user_with_miles, - lonlat: "POINT(13.404954 52.520008)", + lonlat: 'POINT(13.404954 52.520008)', timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80), create(:point, user: user_with_miles, - lonlat: "POINT(13.405954 52.521008)", + lonlat: 'POINT(13.405954 52.521008)', timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78), create(:point, user: user_with_miles, - lonlat: "POINT(13.406954 52.522008)", + lonlat: 'POINT(13.406954 52.522008)', timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76), create(:point, user: user_with_miles, - lonlat: "POINT(13.407954 52.523008)", + lonlat: 'POINT(13.407954 52.523008)', timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74) ] end @@ -429,7 +433,7 @@ RSpec.describe 'Map Interaction', type: :system do else # If we can't trigger the popup, at least verify the setup is correct expect(user_settings.dig('maps', 'distance_unit')).to eq('mi') - puts "Note: Polyline popup interaction could not be triggered in test environment" + puts 'Note: Polyline popup interaction could not be triggered in test environment' end end end @@ -456,7 +460,7 @@ RSpec.describe 'Map Interaction', type: :system do click_button 'Update' end - # Wait for success flash message + # Wait for success flash message expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10) end @@ -710,13 +714,13 @@ RSpec.describe 'Map Interaction', type: :system do it 'allows year selection and month navigation' do # This test is skipped due to calendar panel JavaScript interaction issues # The calendar button exists but the panel doesn't open reliably in test environment - skip "Calendar panel JavaScript interaction needs debugging" + skip 'Calendar panel JavaScript interaction needs debugging' end it 'displays visited cities information' do # This test is skipped due to calendar panel JavaScript interaction issues # The calendar button exists but the panel doesn't open reliably in test environment - skip "Calendar panel JavaScript interaction needs debugging" + skip 'Calendar panel JavaScript interaction needs debugging' end xit 'persists panel state in localStorage' do diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index bc25a57d..86d72768 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -1275,6 +1275,424 @@ paths: responses: '200': description: user found + "/api/v1/visits": + get: + summary: List visits + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + - name: start_at + in: query + required: false + description: Start date (ISO 8601) + schema: + type: string + - name: end_at + in: query + required: false + description: End date (ISO 8601) + schema: + type: string + - name: selection + in: query + required: false + description: Set to "true" for area-based search + schema: + type: string + - name: sw_lat + in: query + required: false + description: Southwest latitude for area search + schema: + type: number + - name: sw_lng + in: query + required: false + description: Southwest longitude for area search + schema: + type: number + - name: ne_lat + in: query + required: false + description: Northeast latitude for area search + schema: + type: number + - name: ne_lng + in: query + required: false + description: Northeast longitude for area search + schema: + type: number + responses: + '200': + description: visits found + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + enum: + - suggested + - confirmed + - declined + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + description: Duration in minutes + place: + type: object + properties: + id: + type: integer + name: + type: string + latitude: + type: number + longitude: + type: number + city: + type: string + country: + type: string + required: + - id + - name + - status + - started_at + - ended_at + - duration + '401': + description: unauthorized + post: + summary: Create visit + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visit created + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + place: + type: object + properties: + id: + type: integer + name: + type: string + latitude: + type: number + longitude: + type: number + '422': + description: invalid request + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit: + type: object + properties: + name: + type: string + latitude: + type: number + longitude: + type: number + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + required: + - name + - latitude + - longitude + - started_at + - ended_at + "/api/v1/visits/{id}": + patch: + summary: Update visit + tags: + - Visits + parameters: + - name: id + in: path + required: true + description: Visit ID + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visit updated + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + place: + type: object + '404': + description: visit not found + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit: + type: object + properties: + name: + type: string + place_id: + type: integer + status: + type: string + enum: + - suggested + - confirmed + - declined + delete: + summary: Delete visit + tags: + - Visits + parameters: + - name: id + in: path + required: true + description: Visit ID + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '204': + description: visit deleted + '404': + description: visit not found + '401': + description: unauthorized + "/api/v1/visits/{id}/possible_places": + get: + summary: Get possible places for visit + tags: + - Visits + parameters: + - name: id + in: path + required: true + description: Visit ID + schema: + type: integer + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: possible places found + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + latitude: + type: number + longitude: + type: number + city: + type: string + country: + type: string + '404': + description: visit not found + '401': + description: unauthorized + "/api/v1/visits/merge": + post: + summary: Merge visits + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visits merged + content: + application/json: + schema: + type: object + properties: + id: + type: integer + name: + type: string + status: + type: string + started_at: + type: string + format: datetime + ended_at: + type: string + format: datetime + duration: + type: integer + place: + type: object + '422': + description: invalid request + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit_ids: + type: array + items: + type: integer + minItems: 2 + description: Array of visit IDs to merge (minimum 2) + required: + - visit_ids + "/api/v1/visits/bulk_update": + post: + summary: Bulk update visits + tags: + - Visits + parameters: + - name: Authorization + in: header + required: true + description: Bearer token + schema: + type: string + responses: + '200': + description: visits updated + content: + application/json: + schema: + type: object + properties: + message: + type: string + updated_count: + type: integer + '422': + description: invalid request + '401': + description: unauthorized + requestBody: + content: + application/json: + schema: + type: object + properties: + visit_ids: + type: array + items: + type: integer + description: Array of visit IDs to update + status: + type: string + enum: + - suggested + - confirmed + - declined + description: New status for the visits + required: + - visit_ids + - status servers: - url: http://{defaultHost} variables: