Compare commits

...

102 commits

Author SHA1 Message Date
Evgenii Burmakin
29f81738df
0.37.2 (#2114)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

* Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully.

* Update version

* Update calculation of time spent in a country for year-end digest email (#2110)

* Update calculation of time spent in a country for year-end digest email

* Add a filter to exclude raw data points when calculating yearly digests.

* Bump trix from 2.1.15 to 2.1.16 in the npm_and_yarn group across 1 directory (#2098)

* 0.37.1 (#2092)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

* Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully.

* Update version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump trix in the npm_and_yarn group across 1 directory

Bumps the npm_and_yarn group with 1 update in the / directory: [trix](https://github.com/basecamp/trix).


Updates `trix` from 2.1.15 to 2.1.16
- [Release notes](https://github.com/basecamp/trix/releases)
- [Commits](https://github.com/basecamp/trix/compare/v2.1.15...v2.1.16)

---
updated-dependencies:
- dependency-name: trix
  dependency-version: 2.1.16
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Map v2 will no longer block the UI when Immich/Photoprism integration has a bad URL or is unreachable (#2113)

* Bump rubocop-rails from 2.33.4 to 2.34.2 (#2080)

Bumps [rubocop-rails](https://github.com/rubocop/rubocop-rails) from 2.33.4 to 2.34.2.
- [Release notes](https://github.com/rubocop/rubocop-rails/releases)
- [Changelog](https://github.com/rubocop/rubocop-rails/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rubocop/rubocop-rails/compare/v2.33.4...v2.34.2)

---
updated-dependencies:
- dependency-name: rubocop-rails
  dependency-version: 2.34.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump chartkick from 5.2.0 to 5.2.1 (#2081)

Bumps [chartkick](https://github.com/ankane/chartkick) from 5.2.0 to 5.2.1.
- [Changelog](https://github.com/ankane/chartkick/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ankane/chartkick/compare/v5.2.0...v5.2.1)

---
updated-dependencies:
- dependency-name: chartkick
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump rubyzip from 3.2.0 to 3.2.2 (#2082)

Bumps [rubyzip](https://github.com/rubyzip/rubyzip) from 3.2.0 to 3.2.2.
- [Release notes](https://github.com/rubyzip/rubyzip/releases)
- [Changelog](https://github.com/rubyzip/rubyzip/blob/main/Changelog.md)
- [Commits](https://github.com/rubyzip/rubyzip/compare/v3.2.0...v3.2.2)

---
updated-dependencies:
- dependency-name: rubyzip
  dependency-version: 3.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump sentry-ruby from 6.0.0 to 6.2.0 (#2083)

Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.2.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.2.0)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump sidekiq from 8.0.8 to 8.1.0 (#2084)

Bumps [sidekiq](https://github.com/sidekiq/sidekiq) from 8.0.8 to 8.1.0.
- [Changelog](https://github.com/sidekiq/sidekiq/blob/main/Changes.md)
- [Commits](https://github.com/sidekiq/sidekiq/compare/v8.0.8...v8.1.0)

---
updated-dependencies:
- dependency-name: sidekiq
  dependency-version: 8.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Update digest calculation to use actual time spent in countries based… (#2115)

* Update digest calculation to use actual time spent in countries based on consecutive points, avoiding double-counting days when crossing borders.

* Move methods to private

* Update Gemfile and Gemfile.lock to pin connection_pool and sidekiq versions

* Rework country tracked days calculation

* Adjust calculate_duration_in_minutes to only count continuous presence within cities, excluding long gaps.

* Move helpers for digest city progress to a helper method

* Implement globe projection option for Map v2 using MapLibre GL JS.

* Update time spent calculation for country minutes in user digests

* Stats are now calculated with more accuracy by storing total minutes spent per country.

* Add globe_projection setting to safe settings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-04 20:05:04 +01:00
Evgenii Burmakin
6ed6a4fd89
0.37.1 (#2092)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

* Fix migrations and data verification to remove safety_assured blocks and handle missing points gracefully.

* Update version

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-30 19:06:10 +01:00
Evgenii Burmakin
8d2ade1bdc
0.37.0 (#2067)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

* Bump sentry-rails from 6.0.0 to 6.1.0 (#1945)

Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/6.0.0...6.1.0)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump turbo-rails from 2.0.17 to 2.0.20 (#1944)

Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.17 to 2.0.20.
- [Release notes](https://github.com/hotwired/turbo-rails/releases)
- [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.17...v2.0.20)

---
updated-dependencies:
- dependency-name: turbo-rails
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump webmock from 3.25.1 to 3.26.1 (#1943)

Bumps [webmock](https://github.com/bblimke/webmock) from 3.25.1 to 3.26.1.
- [Release notes](https://github.com/bblimke/webmock/releases)
- [Changelog](https://github.com/bblimke/webmock/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bblimke/webmock/compare/v3.25.1...v3.26.1)

---
updated-dependencies:
- dependency-name: webmock
  dependency-version: 3.26.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evgenii Burmakin <Freika@users.noreply.github.com>

* Bump brakeman from 7.1.0 to 7.1.1 (#1942)

Bumps [brakeman](https://github.com/presidentbeef/brakeman) from 7.1.0 to 7.1.1.
- [Release notes](https://github.com/presidentbeef/brakeman/releases)
- [Changelog](https://github.com/presidentbeef/brakeman/blob/main/CHANGES.md)
- [Commits](https://github.com/presidentbeef/brakeman/compare/v7.1.0...v7.1.1)

---
updated-dependencies:
- dependency-name: brakeman
  dependency-version: 7.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump redis from 5.4.0 to 5.4.1 (#1941)

Bumps [redis](https://github.com/redis/redis-rb) from 5.4.0 to 5.4.1.
- [Changelog](https://github.com/redis/redis-rb/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/redis-rb/compare/v5.4.0...v5.4.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 5.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Put import deletion into background job (#2045)

* Put import deletion into background job

* Update changelog

* fix null type error and update heatmap styling (#2037)

* fix: use constant weight for maplibre heatmap layer

* fix null type, update heatmap styling

* improve heatmap styling

* fix typo

* Fix stats calculation to recursively reduce H3 resolution when too ma… (#2065)

* Fix stats calculation to recursively reduce H3 resolution when too many hexagons are generated

* Update CHANGELOG.md

* Validate trip start and end dates (#2066)

* Validate trip start and end dates

* Update changelog

* Update migration to clean up duplicate stats before adding unique index

* Fix fog of war radius setting being ignored and applying settings causing errors (#2068)

* Update changelog

* Add Rack::Deflater middleware to config/application.rb to enable gzip compression for responses.

* Add composite index to points on user_id and timestamp

* Deduplicte points based on timestamp brought to unix time

* Fix/stats cache invalidation (#2072)

* Fix family layer toggle in Map v2 settings for non-selfhosted env

* Invalidate cache

* Remove comments

* Remove comment

* Add new indicies to improve performance and remove unused ones to opt… (#2078)

* Add new indicies to improve performance and remove unused ones to optimize database.

* Remove comments

* Update map search suggestions panel styling

* Add yearly digest (#2073)

* Add yearly digest

* Rename YearlyDigests to Users::Digests

* Minor changes

* Update yearly digest layout and styles

* Add flags and chart to email

* Update colors

* Fix layout of stats in yearly digest view

* Remove cron job for yearly digest scheduling

* Update CHANGELOG.md

* Update digest email setting handling

* Allow sharing digest for 1 week or 1 month

* Change Digests Distance to Bigint

* Fix settings page

* Update changelog

* Add RailsPulse (#2079)

* Add RailsPulse

* Add RailsPulse monitoring tool with basic HTTP authentication

* Bring points_count to integer

* Update migration and version

* Update rubocop issues

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robin Tuszik <mail@robin.gg>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-30 17:33:56 +01:00
Evgenii Burmakin
3f0aaa09f5
0.36.4 (#2062)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

* Add composite index to stats table if not exists

* Update changelog

* Update entrypoint to always sync static assets (not only new ones)

* Add family layer to MapLibre maps (#2055)

* Add family layer to MapLibre maps

* Update migration

* Don't show family toggle if feature is disabled

* Update changelog

* Return changelog

* Update changelog

* Update tailwind file

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-26 14:57:55 +01:00
Eugene Burmakin
2a1584e0b8 Fix storage configuration and file extraction 2025-12-26 14:35:04 +01:00
Evgenii Burmakin
c8242ce902
0.36.3 (#2013)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

* Pull only necessary data for map v2 points

* Feature/raw data archive (#2009)

* 0.36.2 (#2007)

* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Remove esbuild scripts from package.json

* Remove sideEffects field from package.json

* Raw data archivation

* Add tests

* Fix tests

* Fix tests

* Update ExceptionReporter

* Add schedule to run raw data archival job monthly

* Change file structure for raw data archival feature

* Update changelog and version for raw data archival feature

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>

* Set raw_data to an empty hash instead of nil when archiving

* Fix storage configuration and file extraction

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018)

* Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation

* Remove raw data from visited cities api endpoint

* Use user timezone to show dates on maps (#2020)

* Fix/pre epoch time (#2019)

* Use user timezone to show dates on maps

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

* Fix tests failing due to new index on stats table

* Fix failing specs

* Update redis client configuration to support unix socket connection

* Update changelog

* Fix kml kmz import issues (#2023)

* Fix kml kmz import issues

* Refactor KML importer to improve readability and maintainability

* Implement moving points in map v2 and fix route rendering logic to ma… (#2027)

* Implement moving points in map v2 and fix route rendering logic to match map v1.

* Fix route spec

* fix(maplibre): update date format to ISO 8601 (#2029)

* Add verification step to raw data archival process (#2028)

* Add verification step to raw data archival process

* Add actual verification of raw data archives after creation, and only clear raw_data for verified archives.

* Fix failing specs

* Eliminate zip-bomb risk

* Fix potential memory leak in js

* Return .keep files

* Use Toast instead of alert for notifications

* Add help section to navbar dropdown

* Update changelog

* Remove raw_data_archival_job

* Ensure file is being closed properly after reading in Archivable concern

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-14 12:05:59 +01:00
Eugene Burmakin
0ed20df08b Remove sideEffects field from package.json 2025-12-06 21:49:30 +01:00
Eugene Burmakin
d50f2cc416 Remove esbuild scripts from package.json 2025-12-06 21:41:59 +01:00
Evgenii Burmakin
8934c29fce
0.36.2 (#2007)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

* Feature/maplibre frontend (#1953)

* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet

* Implement phase 1

* Phases 1-3 + part of 4

* Fix e2e tests

* Phase 6

* Implement fog of war

* Phase 7

* Next step: fix specs, phase 7 done

* Use our own map tiles

* Extract v2 map logic to separate manager classes

* Update settings panel on v2 map

* Update v2 e2e tests structure

* Reimplement location search in maps v2

* Update speed routes

* Implement visits and places creation in v2

* Fix last failing test

* Implement visits merging

* Fix a routes e2e test and simplify the routes layer styling.

* Extract js to modules from maps_v2_controller.js

* Implement area creation

* Fix spec problem

* Fix some e2e tests

* Implement live mode in v2 map

* Update icons and panel

* Extract some styles

* Remove unused file

* Start adding dark theme to popups on MapLibre maps

* Make popups respect dark theme

* Move v2 maps to maplibre namespace

* Update v2 references to maplibre

* Put place, area and visit info into side panel

* Update API to use safe settings config method

* Fix specs

* Fix method name to config in SafeSettings and update usages accordingly

* Add missing public files

* Add handling for real time points

* Fix remembering enabled/disabled layers of the v2 map

* Fix lots of e2e tests

* Add settings to select map version

* Use maps/v2 as main path for MapLibre maps

* Update routing

* Update live mode

* Update maplibre controller

* Update changelog

* Remove some console.log statements

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-12-06 20:54:49 +01:00
Evgenii Burmakin
d5dbf002e0
0.36.1 (#1986)
* fix: move foreman to global gems to fix startup crash (#1971)

* Update exporting code to stream points data to file in batches to red… (#1980)

* Update exporting code to stream points data to file in batches to reduce memory usage

* Update changelog

* Update changelog

---------

Co-authored-by: Robin Tuszik <mail@robin.gg>
2025-11-29 19:59:26 +01:00
Eugene Burmakin
52eb80503d Rework usage of OIDC auto-registration and email/password registration settings to use constants instead of direct ENV access. 2025-11-24 20:04:31 +01:00
Eugene Burmakin
f8be3ecdca Merge branch 'dev' 2025-11-24 19:47:00 +01:00
Eugene Burmakin
573ed510a5 Update changelog 2025-11-24 19:46:27 +01:00
Evgenii Burmakin
b1393ee674
0.36.0 (#1952)
* Implement OmniAuth GitHub authentication

* Fix omniauth GitHub scope to include user email access

* Remove margin-bottom

* Implement Google OAuth2 authentication

* Implement OIDC authentication for Dawarich using omniauth_openid_connect gem.

* Add patreon account linking and patron checking service

* Update docker-compose.yml to use boolean values instead of strings

* Add support for KML files

* Add tests

* Update changelog

* Remove patreon OAuth integration

* Move omniauthable to a concern

* Update an icon in integrations

* Update changelog

* Update app version

* Fix family location sharing toggle

* Move family location sharing to its own controller

* Update changelog

* Implement basic tagging functionality for places, allowing users to categorize and label places with custom tags.

* Add places management API and tags feature

* Add some changes related to places management feature

* Fix some tests

* Fix sometests

* Add places layer

* Update places layer to use Leaflet.Control.Layers.Tree for hierarchical layer control

* Rework tag form

* Add hashtag

* Add privacy zones to tags

* Add notes to places and manage place tags

* Update changelog

* Update e2e tests

* Extract tag serializer to its own file

* Fix some tests

* Fix tags request specs

* Fix some tests

* Fix rest of the tests

* Revert some changes

* Add missing specs

* Revert changes in place export/import code

* Fix some specs

* Fix PlaceFinder to only consider global places when finding existing places

* Fix few more specs

* Fix visits creator spec

* Fix last tests

* Update place creating modal

* Add home location based on "Home" tagged place

* Save enabled tag layers

* Some fixes

* Fix bug where enabling place tag layers would trigger saving enabled layers, overwriting with incomplete data

* Update migration to use disable_ddl_transaction! and add up/down methods

* Fix tag layers restoration and filtering logic

* Update OIDC auto-registration and email/password registration settings

* Fix potential xss
2025-11-24 19:45:09 +01:00
Eugene Burmakin
fa4e368003 Fix potential xss 2025-11-24 19:44:21 +01:00
Eugene Burmakin
4aa6edc3cc Update OIDC auto-registration and email/password registration settings 2025-11-24 19:33:51 +01:00
Eugene Burmakin
f447039bbe Fix tag layers restoration and filtering logic 2025-11-23 23:16:37 +01:00
Eugene Burmakin
289a2a5373 Update migration to use disable_ddl_transaction! and add up/down methods 2025-11-23 01:21:37 +01:00
Eugene Burmakin
6dfc0099e1 Fix bug where enabling place tag layers would trigger saving enabled layers, overwriting with incomplete data 2025-11-23 01:12:38 +01:00
Eugene Burmakin
78ac365c00 Some fixes 2025-11-23 00:43:31 +01:00
Evgenii Burmakin
5266436396
Merge pull request #1955 from Freika/feature/places-management
Feature/places management
2025-11-23 00:13:24 +01:00
Eugene Burmakin
e965838f14 Save enabled tag layers 2025-11-22 23:22:37 +01:00
Eugene Burmakin
a33373ae7c Add home location based on "Home" tagged place 2025-11-22 20:46:35 +01:00
Eugene Burmakin
e1013b1ae1 Update place creating modal 2025-11-22 20:35:51 +01:00
Eugene Burmakin
64d299b363 Fix last tests 2025-11-22 19:45:53 +01:00
Eugene Burmakin
e4fa282eb8 Fix visits creator spec 2025-11-22 18:51:11 +01:00
Eugene Burmakin
ba6314231a Fix few more specs 2025-11-22 18:38:20 +01:00
Eugene Burmakin
b18fc392cc Fix PlaceFinder to only consider global places when finding existing places 2025-11-22 18:32:26 +01:00
Eugene Burmakin
02cbf65781 Fix some specs 2025-11-22 18:27:54 +01:00
Eugene Burmakin
1e2c709047 Revert changes in place export/import code 2025-11-22 13:28:38 +01:00
Eugene Burmakin
50bfece971 Add missing specs 2025-11-20 00:04:37 +01:00
Eugene Burmakin
c99f6597f0 Revert some changes 2025-11-19 21:40:43 +01:00
Eugene Burmakin
491767b114 Fix rest of the tests 2025-11-19 21:19:59 +01:00
Eugene Burmakin
ebd0f8d6bc Fix some tests 2025-11-19 20:56:46 +01:00
Eugene Burmakin
01df22d080 Fix tags request specs 2025-11-19 20:35:43 +01:00
Eugene Burmakin
e02b397b87 Fix some tests 2025-11-19 20:08:49 +01:00
Eugene Burmakin
1d07eb652d Extract tag serializer to its own file 2025-11-19 19:33:28 +01:00
Eugene Burmakin
449884796f Update e2e tests 2025-11-19 19:17:30 +01:00
Eugene Burmakin
4f5903e220 Update changelog 2025-11-18 22:16:43 +01:00
Eugene Burmakin
2ab24201c1 Add notes to places and manage place tags 2025-11-18 22:16:34 +01:00
Eugene Burmakin
c711bed383 Add privacy zones to tags 2025-11-18 21:57:06 +01:00
Eugene Burmakin
bce1052608 Add hashtag 2025-11-18 21:24:14 +01:00
Eugene Burmakin
807672170f Rework tag form 2025-11-18 21:14:12 +01:00
Eugene Burmakin
e8e7bcc91b Update places layer to use Leaflet.Control.Layers.Tree for hierarchical layer control 2025-11-18 21:03:53 +01:00
Eugene Burmakin
602975eeaa Add places layer 2025-11-17 22:34:38 +01:00
Eugene Burmakin
8a36a69987 Fix sometests 2025-11-17 19:05:58 +01:00
Eugene Burmakin
c462d34efa Fix some tests 2025-11-17 00:23:48 +01:00
Eugene Burmakin
e1f16c98a2 Add some changes related to places management feature 2025-11-16 17:50:24 +01:00
Eugene Burmakin
78851c5f98 Add places management API and tags feature 2025-11-16 17:28:40 +01:00
Eugene Burmakin
69c8779164 Implement basic tagging functionality for places, allowing users to categorize and label places with custom tags. 2025-11-16 15:01:54 +01:00
Evgenii Burmakin
284f763be4
Merge pull request #1954 from Freika/fix/family-location-toggle
Fix family location sharing toggle
2025-11-16 13:18:26 +01:00
Eugene Burmakin
541dba1bc6 Update changelog 2025-11-16 13:15:37 +01:00
Eugene Burmakin
3b5f775a4e Move family location sharing to its own controller 2025-11-16 13:12:46 +01:00
Eugene Burmakin
3a2dc1da5a Fix family location sharing toggle 2025-11-16 13:06:31 +01:00
Eugene Burmakin
d1ffc15fea Update app version 2025-11-14 19:11:23 +01:00
Evgenii Burmakin
88134a0a2b
Merge pull request #1880 from Freika/feature/omniauth
Feature/omniauth
2025-11-14 19:10:57 +01:00
Eugene Burmakin
bb574f5aa3 Update changelog 2025-11-14 19:09:57 +01:00
Eugene Burmakin
8ecd75429b Update an icon in integrations 2025-11-14 19:06:16 +01:00
Eugene Burmakin
bedac89821 Move omniauthable to a concern 2025-11-14 18:54:35 +01:00
Eugene Burmakin
a4dcd0387f Remove patreon OAuth integration 2025-11-14 18:39:15 +01:00
Eugene Burmakin
fde478e2a4 Merge branch 'dev' into feature/omniauth 2025-11-14 18:22:36 +01:00
Evgenii Burmakin
cfe5a77a47
Merge pull request #1951 from Freika/feature/kml
Feature/kml
2025-11-14 18:21:01 +01:00
Eugene Burmakin
943b551f4c Update changelog 2025-11-14 18:19:50 +01:00
Eugene Burmakin
3bd59c20c1 Add tests 2025-11-14 18:16:51 +01:00
Eugene Burmakin
8c4d4d5cbe Add support for KML files 2025-11-14 18:04:46 +01:00
Eugene Burmakin
59508ceeff Update docker-compose.yml to use boolean values instead of strings 2025-11-12 22:49:38 +01:00
Eugene Burmakin
8c48c173fb Update docker-compose.yml to use boolean values instead of strings 2025-11-12 22:49:18 +01:00
Evgenii Burmakin
c64dc8b789
Merge pull request #1933 from Freika/dev
0.35.1
2025-11-09 23:09:42 +01:00
Eugene Burmakin
32667590fd Update changelog 2025-11-09 23:09:15 +01:00
Eugene Burmakin
b6d1f1d46d Disable StrongMigrations 2025-11-09 23:08:10 +01:00
Eugene Burmakin
eaf66c8bbd Merge branch 'dev' 2025-11-09 19:42:58 +01:00
Eugene Burmakin
d707bace78 Change node version 2025-11-09 19:42:46 +01:00
Eugene Burmakin
6d905a4466 Merge branch 'dev' 2025-11-09 19:32:27 +01:00
Eugene Burmakin
749d1d0031 Add authorization for updating location sharing in FamiliesController 2025-11-09 19:32:24 +01:00
Eugene Burmakin
dbe5997495 Update compose file 2025-11-09 19:32:07 +01:00
Evgenii Burmakin
58ae4cf2ae
Merge pull request #1922 from Freika/dev
0.35.0
2025-11-09 16:27:29 +01:00
Eugene Burmakin
b0585b2a97 Update changelog 2025-11-09 16:27:00 +01:00
Evgenii Burmakin
6e1c9d7600
Merge branch 'master' into dev 2025-11-09 16:20:06 +01:00
Eugene Burmakin
b0bd2bf93c Update tests 2025-11-09 16:03:05 +01:00
Eugene Burmakin
3061f3e86f Update changelog 2025-11-08 20:21:23 +01:00
Evgenii Burmakin
28bc68ffe2
Merge pull request #1928 from Freika/fix/map-side-panel
Fix/map side panel
2025-11-08 20:00:45 +01:00
Eugene Burmakin
23aa533279 Add a button to close the visits side panel 2025-11-08 19:57:15 +01:00
Eugene Burmakin
18de91e562 Make sure visits keep their collapsible sections open/closed state when the side panel is refreshed. 2025-11-08 19:35:44 +01:00
Eugene Burmakin
486974b993 Make visits look nicer 2025-11-08 17:04:38 +01:00
Eugene Burmakin
73f93b6a57 Side panel content centered 2025-11-08 16:41:42 +01:00
Eugene Burmakin
4a022d9695 Open side panel on the left of controls 2025-11-08 16:05:46 +01:00
Evgenii Burmakin
2a8522272d
Merge pull request #1925 from Freika/chore/single-dockerfile
Unify Dockerfile
2025-11-07 23:53:23 +01:00
Eugene Burmakin
a6687eca18 Update changelog 2025-11-07 23:52:58 +01:00
Eugene Burmakin
c05402b6f3 Allow S3 storage backend to be used in self-hosted instances 2025-11-07 23:50:18 +01:00
Eugene Burmakin
98a157bd0b Update changelog 2025-11-07 23:42:21 +01:00
Eugene Burmakin
e4f80dbf2b Remove safety_assured 2025-11-07 20:23:53 +01:00
Eugene Burmakin
f48a512b10 Remove strong_migrations gem for now 2025-11-07 20:18:56 +01:00
Eugene Burmakin
b272c7407f Update strong_migrations config 2025-11-07 20:09:29 +01:00
Eugene Burmakin
691ff63b87 Update strong_migrations config 2025-11-07 20:02:51 +01:00
Evgenii Burmakin
5a9bdfea5f
Merge pull request #1912 from Freika/dev
0.34.2
2025-10-31 19:30:31 +01:00
Eugene Burmakin
5a40f9fe90 Add patreon account linking and patron checking service 2025-10-30 19:18:08 +01:00
Eugene Burmakin
7bc579e563 REVERT: Patreon account connection 2025-10-29 13:27:43 +01:00
Eugene Burmakin
e6c8bd30df Implement OIDC authentication for Dawarich using omniauth_openid_connect gem. 2025-10-26 18:10:48 +01:00
Eugene Burmakin
48e50c2ee8 Implement Google OAuth2 authentication 2025-10-26 15:50:47 +01:00
Eugene Burmakin
af71661e2b Remove margin-bottom 2025-10-26 15:34:12 +01:00
Eugene Burmakin
44bbbd09b7 Fix omniauth GitHub scope to include user email access 2025-10-26 15:32:26 +01:00
Eugene Burmakin
f5dc7a10a3 Implement OmniAuth GitHub authentication 2025-10-26 15:27:43 +01:00
437 changed files with 91676 additions and 1590 deletions

View file

@ -1 +1 @@
0.34.2
0.37.2

View file

@ -4,7 +4,159 @@ 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/).
# [UNRELEASED]
# [0.37.2] - 2026-01-04
## Fixed
- Months are now correctly ordered (Jan-Dec) in the year-end digest chart instead of being sorted alphabetically.
- Time spent in a country and city is now calculated correctly for the year-end digest email. #2104
- Updated Trix to fix a XSS vulnerability. #2102
- Map v2 UI no longer blocks when Immich/Photoprism integration has a bad URL or is unreachable. Added 10-second timeout to photo API requests and improved error handling to prevent UI freezing during initial load. #2085
- In Map v2 settings, you can now enable map to be rendered as a globe.
# [0.37.1] - 2025-12-30
## Fixed
- The db migration preventing the app from starting.
- Raw data archive verifier now allows having points deleted from the db after archiving.
# [0.37.0] - 2025-12-30
## Added
- In the beginning of the year users will receive a year-end digest email with stats about their tracking activity during the past year. Users can opt out of receiving these emails in User Settings -> Notifications. Emails won't be sent if no email is configured in the SMTP settings or if user has no points tracked during the year.
## Changed
- Added and removed some indexes to improve the app performance based on the production usage data.
## Changed
- Deleting an import will now be processed in the background to prevent request timeouts for large imports.
## Fixed
- Deleting an import will no longer result in negative points count for the user.
- Updating stats. #2022
- Validate trip start date to be earlier than end date. #2057
- Fog of war radius slider in map v2 settings is now being respected correctly. #2041
- Applying changes in map v2 settings now works correctly. #2041
- Invalidate stats cache on recalculation and other operations that change stats data.
# [0.36.4] - 2025-12-26
## Fixed
- Fixed a bug preventing the app to start if a composite index on stats table already exists. #2034 #2051 #2046
- New compiled assets will override old ones on app start to prevent serving stale assets.
- Number of points in stats should no longer go negative when points are deleted. #2054
- Disable Family::Invitations::CleanupJob no invitations are in the database. #2043
- User can now enable family layer in Maps v2 and center on family members by clicking their emails. #2036
# [0.36.3] - 2025-12-14
## Added
- Setting `ARCHIVE_RAW_DATA` env var to true will enable monthly raw data archiving for all users. It will look for points older than 2 months with `raw_data` column not empty and create a zip archive containing raw data files for each month. After successful archiving, raw data will be removed from the database to save space. Monthly archiving job is being run every day at 2:00 AM. Default env var value is false.
- In map v2, user can now move points when Points layer is enabled. #2024
- In map v2, routes are now being rendered using same logic as in map v1, route-length-wise. #2026
## Fixed
- Cities visited during a trip are now being calculated correctly. #547 #641 #1686 #1976
- Points on the map are now show time in user's timezone. #580 #1035 #1682
- Date range inputs now handle pre-epoch dates gracefully by clamping to valid PostgreSQL integer range. #685
- Redis client now also being configured so that it could connect via unix socket. #1970
- Importing KML files now creates points with correct timestamps. #1988
- Importing KMZ files now works correctly.
- Map settings are now being respected in map v2. #2012
# [0.36.2] - 2025-12-06
## The Map v2 release
In this release we're introducing Map v2 based on MapLibre GL JS. It brings better performance, smoother interactions and more features in the future. User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the Settings -> Map Settings. New map features will be added to Map v2 only.
## Added
- User can select between Map v1 (Leaflet) and Map v2 (MapLibre GL JS) in the User Settings.
## Fixed
- Heatmap and Fog of War now are moving correctly during map interactions on v2 map. #1798
- Polyline crossing international date line now are rendered correctly on v2 map. #1162
- Place popup tags parsing (MapLibre GL JS compatibility)
- Stats calculation should be faster now.
# [0.36.1] - 2025-11-29
## Fixed
- Exporting user data now works a lot faster and consumes less memory.
- Fix the restart loop. #1937 #1975
# [0.36.0] - 2025-11-24
## OIDC and KML support release
So, you want to configure your OIDC provider. If not — skip to the actual changelog. You're going to need to provide at least 4 environment variables: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER`, and `OIDC_REDIRECT_URI`. Then, if you want to rename the provider from "OpenID Connect" to something else (e.g. "Authentik"), set `OIDC_PROVIDER_NAME` variable as well. If you want to disable email/password registration and allow only OIDC login, set `ALLOW_EMAIL_PASSWORD_REGISTRATION` to `false`. After just 7 brand new environment variables, you'll never have to deal with passwords in Dawarich again!
Jokes aside, even though I'm not a fan of bloating the environment with too many variables, this is a nice addition and it will be reused in the cloud version of Dawarich as well. Thanks for waiting more than a year for this feature!
To configure your OIDC provider, set the following environment variables:
```
OIDC_CLIENT_ID=client_id_example
OIDC_CLIENT_SECRET=client_secret_example
OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/
OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback
OIDC_AUTO_REGISTER=true # optional, default is false
OIDC_PROVIDER_NAME=YourProviderName # optional, default is OpenID Connect
ALLOW_EMAIL_PASSWORD_REGISTRATION=false # optional, default is true
```
## Added
- Support for KML file uploads. #350
- Added a commented line in the `docker-compose.yml` file to use an alternative PostGIS image for ARM architecture.
- User can now create a place directly from the map and add tags and notes to it. If reverse geocoding is enabled, list of nearby places will be shown as suggestions.
- User can create and manage tags for places.
- Visits for manually created places are being suggested automatically, just like for areas.
- User can enable or disable places layers on the map to show/hide all or just some of their visited places based on tags.
- User can define privacy zones around places with specific tags to hide map data within a certain radius.
- If user has a place tagged with a tag named "Home" (case insensitive), and this place doesn't have a privacy zone defined, this place will be used as home location for days with no tracked data. #1659 #1575
## Fixed
- The map settings panel is now scrollable
- Fixed a bug where family location sharing settings were not being updated correctly. #1940
## Changed
- Internal redis settings updated to implement support for connecting to Redis via unix socket. #1706
- Implemented authentication via GitHub and Google for Dawarich Cloud.
- Implemented OpenID Connect authentication for self-hosted Dawarich instances. #66
# [0.35.1] - 2025-11-09
## Fixed
- StrongMigration issue #1931
# [0.35.0] - 2025-11-09
⚠️ Important ⚠️
The default `docker-compose.yml` file has been updated to provide sensible defaults for self-hosted production environments. This should not break existing setups, but it's recommended to review your `docker-compose.yml` file and update it accordingly.
You can now set `RAILS_ENV` environment variable to `production` to run Dawarich in production mode.
## Added
@ -15,17 +167,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Taiwan flag is now shown on its own instead of in combination with China flag.
- On the registration page and other user forms, if something goes wrong, error messages are now shown to the user.
- Leaving family, deleting family and cancelling invitations now prompt confirmation dialog to prevent accidental actions.
- Each pending family invitation now also contain a link to share with the invitee.
- Each pending family invitation now also contains a link to share with the invitee.
## Changed
- Removed useless system tests and cover map functionality with Playwright e2e tests instead.
- Number of family members on self-hosted instances is no longer limited.
- Export to GPX now adds adds speed and course to each point if they are available.
- S3 storage now can be used in self-hosted instances as well. Set STORAGE_BACKEND environment variable to `s3` and provide `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, `AWS_BUCKET` and `AWS_ENDPOINT_URL` environment variables to configure it.
- Number of family members on self-hosted instances is no longer limited. #1918
- Export to GPX now adds speed and course to each point if they are available.
- `docker-compose.yml` file updated to provide sensible defaults for self-hosted production environment.
- `.env.example` file added with default environment variables.
- Single Dockerfile introduced so Dawarich could be run in self-hosted mode in production environment.
- [ ] Check if with no changes to docker-compose.yml everything still works as before.
- [ ] Deploy to Staging and test again.
# [0.34.2] - 2025-10-31

15
Gemfile
View file

@ -5,15 +5,17 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby File.read('.ruby-version').strip
gem 'activerecord-postgis-adapter', '~> 11.0'
gem 'activerecord-postgis-adapter', '11.0'
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
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 'connection_pool', '< 3' # Pin to 2.x - version 3.0+ has breaking API changes with Rails RedisCacheStore
gem 'data_migrate'
gem 'devise'
gem 'foreman'
gem 'geocoder', github: 'Freika/geocoder', branch: 'master'
gem 'gpx'
gem 'groupdate'
@ -24,6 +26,10 @@ gem 'jwt', '~> 2.8'
gem 'kaminari'
gem 'lograge'
gem 'oj'
gem 'omniauth-github', '~> 2.0.0'
gem 'omniauth-google-oauth2'
gem 'omniauth_openid_connect'
gem 'omniauth-rails_csrf_protection'
gem 'parallel'
gem 'pg'
gem 'prometheus_exporter'
@ -31,6 +37,7 @@ gem 'puma'
gem 'pundit', '>= 2.5.1'
gem 'rails', '~> 8.0'
gem 'rails_icons'
gem 'rails_pulse'
gem 'redis'
gem 'rexml'
gem 'rgeo'
@ -42,16 +49,16 @@ gem 'rswag-ui'
gem 'rubyzip', '~> 3.2'
gem 'sentry-rails', '>= 5.27.0'
gem 'sentry-ruby'
gem 'sidekiq', '>= 8.0.5'
gem 'sidekiq', '8.0.10' # Pin to 8.0.x - sidekiq 8.1+ requires connection_pool 3.0+ which has breaking changes with Rails
gem 'sidekiq-cron', '>= 2.3.1'
gem 'sidekiq-limit_fetch'
gem 'sprockets-rails'
gem 'stackprof'
gem 'stimulus-rails'
gem 'strong_migrations', '>= 2.4.0'
gem 'tailwindcss-rails', '= 3.3.2'
gem 'turbo-rails', '>= 2.0.17'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
gem 'with_advisory_lock'
group :development, :test, :staging do
gem 'brakeman', require: false
@ -78,6 +85,6 @@ end
group :development do
gem 'database_consistency', '>= 2.0.5', require: false
gem 'foreman'
gem 'rubocop-rails', '>= 2.33.4', require: false
gem 'strong_migrations', '>= 2.4.0'
end

View file

@ -86,8 +86,10 @@ GEM
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
ast (2.4.3)
attr_extras (7.1.0)
attr_required (1.0.2)
aws-eventstream (1.3.2)
aws-partitions (1.1072.0)
aws-sdk-core (3.215.1)
@ -106,11 +108,12 @@ GEM
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.3.1)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindata (2.5.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.1.0)
brakeman (7.1.1)
racc
builder (3.3.0)
bundler-audit (0.9.2)
@ -126,18 +129,19 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.2.0)
chartkick (5.2.1)
chunky_png (1.4.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
crack (1.0.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
css-zero (1.1.15)
csv (3.3.4)
data_migrate (11.3.1)
activerecord (>= 6.1)
@ -161,7 +165,9 @@ GEM
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
erb (5.1.3)
email_validator (2.2.4)
activemodel
erb (6.0.0)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@ -171,6 +177,14 @@ GEM
factory_bot (~> 6.5)
railties (>= 6.1.0)
fakeredis (0.1.4)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.4.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
ffaker (2.25.0)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm-linux-gnu)
@ -195,24 +209,32 @@ GEM
ffi (~> 1.9)
rgeo-geojson (~> 2.1)
zeitwerk (~> 2.5)
hashdiff (1.1.2)
hashdiff (1.2.1)
hashie (5.0.0)
httparty (0.23.1)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.7)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.2)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.1)
irb (1.15.2)
irb (1.15.3)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.15.0)
json (2.18.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
json-schema (5.0.1)
addressable (~> 2.8)
jwt (2.10.1)
@ -251,11 +273,14 @@ GEM
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.0)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.7.3)
multi_json (1.15.0)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
net-http (0.6.0)
uri
net-imap (0.5.12)
date
net-protocol
@ -279,14 +304,60 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
oauth2 (2.0.17)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.3)
version_gem (~> 1.1, >= 1.1.9)
oj (3.16.11)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-github (2.0.1)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-google-oauth2 (1.2.1)
jwt (>= 2.9.2)
oauth2 (~> 2.0)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth_openid_connect (0.8.0)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.1)
activemodel
attr_required (>= 1.0.0)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_url
webfinger (~> 2.0)
optimist (3.2.1)
orm_adapter (0.5.0)
ostruct (0.6.1)
pagy (43.2.2)
json
yaml
parallel (1.27.0)
parser (3.3.9.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
patience_diff (1.2.0)
@ -299,7 +370,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.5.1)
prism (1.7.0)
prometheus_exporter (2.2.0)
webrick
pry (0.15.2)
@ -313,14 +384,25 @@ GEM
psych (5.2.6)
date
stringio
public_suffix (6.0.1)
public_suffix (6.0.2)
puma (7.1.0)
nio4r (~> 2.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.3)
rack (3.2.4)
rack-oauth2 (2.3.0)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@ -352,6 +434,14 @@ GEM
rails_icons (1.4.0)
nokogiri (~> 1.16, >= 1.16.4)
rails (> 6.1)
rails_pulse (0.2.4)
css-zero (~> 1.1, >= 1.1.4)
groupdate (~> 6.0)
pagy (>= 8, < 44)
rails (>= 7.1.0, < 9.0.0)
ransack (~> 4.0)
request_store (~> 1.5)
turbo-rails (~> 2.0.11)
railties (8.0.3)
actionpack (= 8.0.3)
activesupport (= 8.0.3)
@ -363,16 +453,20 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (6.15.0)
ransack (4.4.1)
activerecord (>= 7.2)
activesupport (>= 7.2)
i18n
rdoc (6.16.1)
erb
psych (>= 4.0.0)
tsort
redis (5.4.0)
redis (5.4.1)
redis-client (>= 0.22.0)
redis-client (0.24.0)
redis-client (0.26.2)
connection_pool
regexp_parser (2.11.3)
reline (0.6.2)
reline (0.6.3)
io-console (~> 0.5)
request_store (1.7.0)
rack (>= 1.4)
@ -419,7 +513,7 @@ GEM
rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.81.1)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -427,20 +521,20 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.49.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-rails (2.33.4)
prism (~> 1.7)
rubocop-rails (2.34.2)
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 (3.2.0)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.35.0)
base64 (~> 0.2)
@ -448,15 +542,15 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.0.0)
sentry-rails (6.2.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.0.0)
sentry-ruby (6.0.0)
sentry-ruby (~> 6.2.0)
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (8.0.8)
sidekiq (8.0.10)
connection_pool (>= 2.5.0)
json (>= 2.9.0)
logger (>= 1.6.2)
@ -475,6 +569,9 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4)
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
@ -485,13 +582,18 @@ GEM
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
stringio (3.1.8)
strong_migrations (2.5.1)
activerecord (>= 7.1)
super_diff (0.17.0)
attr_extras (>= 6.2.4)
diff-lcs
patience_diff
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
tailwindcss-rails (3.3.2)
railties (>= 7.0.0)
tailwindcss-ruby (~> 3.0)
@ -504,7 +606,7 @@ GEM
thor (1.4.0)
timeout (0.4.4)
tsort (0.2.0)
turbo-rails (2.0.17)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@ -512,12 +614,20 @@ GEM
unicode (0.4.4.5)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.4)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
version_gem (1.1.9)
warden (1.2.9)
rack (>= 2.0.9)
webmock (3.25.1)
webfinger (2.1.3)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@ -527,8 +637,12 @@ GEM
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
with_advisory_lock (7.0.2)
activerecord (>= 7.2)
zeitwerk (>= 2.7)
xpath (3.2.0)
nokogiri (~> 1.8)
yaml (0.4.0)
zeitwerk (2.7.3)
PLATFORMS
@ -540,7 +654,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
activerecord-postgis-adapter (~> 11.0)
activerecord-postgis-adapter (= 11.0)
aws-sdk-core (~> 3.215.1)
aws-sdk-kms (~> 1.96.0)
aws-sdk-s3 (~> 1.177.0)
@ -549,6 +663,7 @@ DEPENDENCIES
bundler-audit
capybara
chartkick
connection_pool (< 3)
data_migrate
database_consistency (>= 2.0.5)
debug
@ -568,6 +683,10 @@ DEPENDENCIES
kaminari
lograge
oj
omniauth-github (~> 2.0.0)
omniauth-google-oauth2
omniauth-rails_csrf_protection
omniauth_openid_connect
parallel
pg
prometheus_exporter
@ -577,6 +696,7 @@ DEPENDENCIES
pundit (>= 2.5.1)
rails (~> 8.0)
rails_icons
rails_pulse
redis
rexml
rgeo
@ -593,7 +713,7 @@ DEPENDENCIES
sentry-rails (>= 5.27.0)
sentry-ruby
shoulda-matchers
sidekiq (>= 8.0.5)
sidekiq (= 8.0.10)
sidekiq-cron (>= 2.3.1)
sidekiq-limit_fetch
simplecov
@ -606,6 +726,7 @@ DEPENDENCIES
turbo-rails (>= 2.0.17)
tzinfo-data
webmock
with_advisory_lock
RUBY VERSION
ruby 3.4.6p54

View file

@ -2,8 +2,6 @@
[![Discord](https://dcbadge.limes.pink/api/server/pHsBjpt5J8)](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD) | [![Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dfreika%26type%3Dpatrons&style=for-the-badge)](https://www.patreon.com/freika)
[![CircleCI](https://circleci.com/gh/Freika/dawarich.svg?style=svg)](https://app.circleci.com/pipelines/github/Freika/dawarich)
---
## 📸 Screenshots
@ -73,12 +71,14 @@ Simply install one of the supported apps on your device and configure it to send
1. Clone the repository.
2. Run the following command to start the app:
```bash
docker-compose -f docker/docker-compose.yml up
docker compose -f docker/docker-compose.yml up
```
3. Access the app at `http://localhost:3000`.
⏹️ **To stop the app**, press `Ctrl+C`.
You can use default values or create a `.env` file based on `.env.example` to customize your setup.
---
## 🔧 How to Install Dawarich

File diff suppressed because one or more lines are too long

View file

@ -27,9 +27,13 @@
/* Style for the settings panel */
.leaflet-settings-panel {
background-color: white;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
position: absolute !important;
top: 10px !important;
left: 60px !important;
transform: none;
z-index: 1000;
}
.leaflet-settings-panel label {

View file

@ -24,7 +24,8 @@
/* Leaflet Panel Styles */
.leaflet-right-panel {
margin-top: 80px; /* Give space for controls above */
margin-top: 80px;
/* Give space for controls above */
margin-right: 10px;
transform: none;
transition: right 0.3s ease-in-out;
@ -52,10 +53,12 @@
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);
@ -76,33 +79,51 @@
/* Drawer Panel Styles */
.leaflet-drawer {
position: absolute;
top: 0;
right: 0;
width: 338px;
height: 100%;
top: 10px;
right: 70px;
/* Position to the left of the control buttons with margin */
width: 24rem;
max-height: calc(100% - 20px);
background: rgba(255, 255, 255, 0.5);
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
border-radius: 8px;
opacity: 0;
visibility: hidden;
transform: scale(0.95);
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out, visibility 0.2s;
z-index: 450;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
height: auto;
/* Make height fit content */
cursor: default;
/* Override map cursor */
}
.leaflet-drawer * {
cursor: default;
/* Ensure all children have default cursor */
}
.leaflet-drawer a,
.leaflet-drawer button,
.leaflet-drawer .btn,
.leaflet-drawer input[type="checkbox"] {
cursor: pointer;
/* Interactive elements get pointer cursor */
}
.leaflet-drawer.open {
transform: translateX(0);
opacity: 1;
visibility: visible;
transform: scale(1);
}
/* Controls transition */
/* Controls remain in place - no transition needed */
.leaflet-control-layers,
.leaflet-control-button,
.toggle-panel-button {
transition: right 0.3s ease-in-out;
z-index: 500;
}
.controls-shifted {
right: 338px !important;
}
/* Selection Tool Styles */
.leaflet-control-custom {
background-color: white;
@ -127,6 +148,61 @@
/* Cancel Selection Button */
#cancel-selection-button {
margin-bottom: 1rem;
width: 100%;
}
/* Emoji Picker Styles */
em-emoji-picker {
--color-border-over: rgba(0, 0, 0, 0.1);
--color-border: rgba(0, 0, 0, 0.05);
--font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--rgb-accent: 96, 165, 250;
/* Blue accent to match application */
position: absolute;
z-index: 1000;
max-width: 400px;
min-width: 318px;
resize: horizontal;
overflow: auto;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
/* Dark mode support for emoji picker */
[data-theme="dark"] em-emoji-picker,
html.dark em-emoji-picker {
--color-border-over: rgba(255, 255, 255, 0.1);
--color-border: rgba(255, 255, 255, 0.05);
--rgb-accent: 96, 165, 250;
}
/* Responsive emoji picker on mobile */
@media (max-width: 768px) {
em-emoji-picker {
max-width: 90vw;
min-width: 280px;
}
}
/* Color Picker Styles */
.color-input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: none;
padding: 0;
}
.color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.color-input::-webkit-color-swatch {
border: none;
border-radius: 0.5rem;
}
.color-input::-moz-color-swatch {
border: none;
border-radius: 0.5rem;
}

View file

@ -0,0 +1,36 @@
.leaflet-control-layers-toggle.leaflet-layerstree-named-toggle {
margin: 2px 5px;
width: auto;
height: auto;
background-image: none;
}
.leaflet-layerstree-header input {
margin-left: 0px;
}
.leaflet-layerstree-header label {
display: inline-block;
cursor: pointer;
}
.leaflet-layerstree-header-pointer,
.leaflet-layerstree-expand-collapse {
cursor: pointer;
}
.leaflet-layerstree-children {
padding-left: 10px;
}
.leaflet-layerstree-children-nopad {
padding-left: 0px;
}
.leaflet-layerstree-hide,
.leaflet-layerstree-nevershow {
display: none;
}
.leaflet-control-layers label {
line-height: 1.5rem!important;
}

View file

@ -49,14 +49,41 @@
}
/* Leaflet layer control */
.leaflet-control-layers-toggle {
.leaflet-control-layers {
border: none !important;
border-radius: 0.5rem !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
background-color: var(--leaflet-bg-color) !important;
color: var(--leaflet-text-color) !important;
padding: 0 !important;
}
.leaflet-control-layers-expanded {
padding: 1rem !important;
min-width: 200px;
}
/* Hide the toggle icon when expanded */
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none !important;
}
.leaflet-control-layers-toggle {
width: 44px !important;
height: 44px !important;
background-color: var(--leaflet-bg-color) !important;
color: var(--leaflet-text-color) !important;
border-radius: 0.5rem !important;
/* Replace default icon with custom SVG */
background-image: none !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: background-color 0.2s;
}
.leaflet-control-layers-toggle:hover {
background-color: var(--leaflet-hover-color) !important;
}
.leaflet-control-layers-toggle::before {
@ -80,13 +107,95 @@
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>') !important;
}
.leaflet-control-layers-expanded {
background-color: var(--leaflet-bg-color) !important;
/* Layer list styling */
.leaflet-control-layers-list {
margin-bottom: 0 !important;
}
.leaflet-control-layers-base,
.leaflet-control-layers-overlays {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.leaflet-control-layers-separator {
height: 1px;
margin: 0.75rem 0;
background-color: var(--leaflet-border-color);
}
/* Label styling */
.leaflet-control-layers label {
display: flex !important;
align-items: center !important;
margin-bottom: 0 !important;
cursor: pointer;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--leaflet-text-color) !important;
}
.leaflet-control-layers label {
color: var(--leaflet-text-color) !important;
.leaflet-control-layers label:hover {
opacity: 0.8;
}
.leaflet-control-layers label span {
margin-left: 0.5rem;
}
/* Custom Checkbox/Radio styling using DaisyUI/Tailwind logic */
.leaflet-control-layers input[type="checkbox"],
.leaflet-control-layers input[type="radio"] {
appearance: none;
width: 1.25rem;
height: 1.25rem;
border: 1px solid var(--leaflet-border-color);
border-radius: 0.25rem;
/* Rounded for checkbox */
background-color: var(--leaflet-bg-color);
cursor: pointer;
position: relative;
margin: 0 !important;
flex-shrink: 0;
}
.leaflet-control-layers input[type="radio"] {
border-radius: 9999px;
/* Circle for radio */
}
.leaflet-control-layers input[type="checkbox"]:checked,
.leaflet-control-layers input[type="radio"]:checked {
background-color: var(--leaflet-link-color);
border-color: var(--leaflet-link-color);
}
/* Checkbox checkmark */
.leaflet-control-layers input[type="checkbox"]:checked::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0.65rem;
height: 0.65rem;
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
background-size: contain;
background-repeat: no-repeat;
transform: translate(-50%, -50%);
}
/* Radio dot */
.leaflet-control-layers input[type="radio"]:checked::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0.5rem;
height: 0.5rem;
background-color: white;
border-radius: 50%;
transform: translate(-50%, -50%);
}
/* Leaflet Draw controls */
@ -188,7 +297,7 @@
color: #f9fafb !important;
}
.leaflet-popup-content-wrapper:has(.family-member-popup) + .leaflet-popup-tip {
.leaflet-popup-content-wrapper:has(.family-member-popup)+.leaflet-popup-tip {
background-color: #1f2937 !important;
}
@ -197,9 +306,11 @@
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);
}
@ -210,7 +321,7 @@
border-radius: 50% !important;
}
.family-member-marker-recent .leaflet-marker-icon > div {
.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%;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,187 @@
/* Maps V2 Styles */
/* Loading Overlay */
.loading-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-overlay.hidden {
display: none;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 16px;
font-size: 14px;
color: #6b7280;
}
/* Popup Styles */
.point-popup {
font-family: system-ui, -apple-system, sans-serif;
}
.popup-header {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.popup-body {
font-size: 13px;
}
.popup-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 4px 0;
}
.popup-row .label {
color: #6b7280;
}
.popup-row .value {
font-weight: 500;
color: #111827;
}
/* MapLibre Popup Theme Support */
.maplibregl-popup-content {
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
/* Larger close button */
.maplibregl-popup-close-button {
width: 32px;
height: 32px;
font-size: 24px;
line-height: 32px;
right: 4px;
top: 4px;
padding: 0;
border-radius: 4px;
transition: background-color 0.2s;
}
.maplibregl-popup-close-button:hover {
background-color: rgba(0, 0, 0, 0.08);
}
/* Light theme (default) */
.maplibregl-popup-content {
background-color: #ffffff;
color: #111827;
}
.maplibregl-popup-close-button {
color: #6b7280;
}
.maplibregl-popup-close-button:hover {
background-color: #f3f4f6;
color: #111827;
}
.maplibregl-popup-tip {
border-top-color: #ffffff;
}
/* Dark theme */
html[data-theme="dark"] .maplibregl-popup-content,
html.dark .maplibregl-popup-content {
background-color: #1f2937;
color: #f9fafb;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
html[data-theme="dark"] .maplibregl-popup-close-button,
html.dark .maplibregl-popup-close-button {
color: #d1d5db;
}
html[data-theme="dark"] .maplibregl-popup-close-button:hover,
html.dark .maplibregl-popup-close-button:hover {
background-color: #374151;
color: #f9fafb;
}
html[data-theme="dark"] .maplibregl-popup-tip,
html.dark .maplibregl-popup-tip {
border-top-color: #1f2937;
}
/* Connection Indicator */
.connection-indicator {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: white;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: none; /* Hidden by default, shown when family sharing is active */
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
z-index: 20;
transition: all 0.3s;
}
/* Show connection indicator when family sharing is active */
.connection-indicator.active {
display: flex;
}
.indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
animation: pulse 2s ease-in-out infinite;
}
.connection-indicator.connected .indicator-dot {
background: #22c55e;
}
.connection-indicator.connected .indicator-text::before {
content: 'Connected';
}
.connection-indicator.disconnected .indicator-text::before {
content: 'Connecting...';
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View file

@ -0,0 +1,286 @@
/* Maps V2 Control Panel Styles */
.map-control-panel {
position: absolute;
top: 0;
right: -480px; /* Hidden by default */
width: 480px;
height: 100%;
background: oklch(var(--b1));
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
z-index: 9999;
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
overflow: hidden;
}
.map-control-panel.open {
right: 0;
}
/* Vertical Tab Bar */
.panel-tabs {
width: 64px;
background: oklch(var(--b2));
border-right: 1px solid oklch(var(--bc) / 0.1);
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 0;
gap: 8px;
flex-shrink: 0;
}
.tab-btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s;
position: relative;
color: oklch(var(--bc) / 0.6);
}
.tab-btn:hover {
background: oklch(var(--b3));
color: oklch(var(--bc));
}
.tab-btn.active {
background: oklch(var(--p));
color: oklch(var(--pc));
}
.tab-btn.active::after {
content: '';
position: absolute;
right: -1px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
background: oklch(var(--p));
border-radius: 2px 0 0 2px;
}
.tab-icon {
width: 24px;
height: 24px;
}
/* Panel Content */
.panel-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid oklch(var(--bc) / 0.1);
background: oklch(var(--b1));
flex-shrink: 0;
}
.panel-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: oklch(var(--bc));
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* Tab Content */
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Custom Scrollbar */
.panel-body::-webkit-scrollbar {
width: 8px;
}
.panel-body::-webkit-scrollbar-track {
background: transparent;
}
.panel-body::-webkit-scrollbar-thumb {
background: oklch(var(--bc) / 0.2);
border-radius: 4px;
}
.panel-body::-webkit-scrollbar-thumb:hover {
background: oklch(var(--bc) / 0.3);
}
/* Toggle Focus State - Remove all focus indicators */
.toggle:focus,
.toggle:focus-visible,
.toggle:focus-within {
outline: none !important;
box-shadow: none !important;
border-color: inherit !important;
}
/* Override DaisyUI toggle focus styles */
.toggle:focus-visible:checked,
.toggle:checked:focus,
.toggle:checked:focus-visible {
outline: none !important;
box-shadow: none !important;
}
/* Ensure no outline on the toggle container */
.form-control .toggle:focus {
outline: none !important;
}
/* Prevent indeterminate visual state on toggles */
.toggle:indeterminate {
opacity: 1;
}
/* Ensure smooth toggle transitions without intermediate states */
.toggle {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.toggle:checked {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
/* Remove any active/pressed state that might cause intermediate appearance */
.toggle:active,
.toggle:active:focus {
outline: none !important;
box-shadow: none !important;
}
/* Responsive Breakpoints */
/* Large tablets and smaller desktops (1024px - 1280px) */
@media (max-width: 1280px) {
.map-control-panel {
width: 420px;
right: -420px;
}
}
/* Tablets (768px - 1024px) */
@media (max-width: 1024px) {
.map-control-panel {
width: 380px;
right: -380px;
}
.panel-body {
padding: 20px;
}
}
/* Small tablets and large phones (640px - 768px) */
@media (max-width: 768px) {
.map-control-panel {
width: 95%;
right: -95%;
max-width: 480px;
}
.panel-header {
padding: 16px 20px;
}
.panel-title {
font-size: 1.125rem;
}
.panel-body {
padding: 16px 20px;
}
}
/* Mobile phones (< 640px) */
@media (max-width: 640px) {
.map-control-panel {
width: 100%;
right: -100%;
max-width: none;
}
.panel-tabs {
width: 56px;
padding: 12px 0;
gap: 6px;
}
.tab-btn {
width: 44px;
height: 44px;
}
.tab-icon {
width: 20px;
height: 20px;
}
.panel-header {
padding: 14px 16px;
}
.panel-title {
font-size: 1rem;
}
.panel-body {
padding: 16px;
}
/* Reduce spacing on mobile */
.space-y-4 > * + * {
margin-top: 0.75rem;
}
.space-y-6 > * + * {
margin-top: 1rem;
}
}
/* Very small phones (< 375px) */
@media (max-width: 375px) {
.panel-tabs {
width: 52px;
padding: 10px 0;
}
.tab-btn {
width: 40px;
height: 40px;
}
.panel-header {
padding: 12px;
}
.panel-body {
padding: 12px;
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-big-down-icon lucide-arrow-big-down"><path d="M15 11a1 1 0 0 0 1 1h2.939a1 1 0 0 1 .75 1.811l-6.835 6.836a1.207 1.207 0 0 1-1.707 0L4.31 13.81a1 1 0 0 1 .75-1.811H8a1 1 0 0 0 1-1V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1z"/></svg>

After

Width:  |  Height:  |  Size: 429 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-plus2-icon lucide-calendar-plus-2"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M10 16h4"/><path d="M12 14v4"/></svg>

After

Width:  |  Height:  |  Size: 399 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>

After

Width:  |  Height:  |  Size: 316 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-grid2x2-icon lucide-grid-2x2"><path d="M12 3v18"/><path d="M3 12h18"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layers-icon lucide-layers"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>

After

Width:  |  Height:  |  Size: 526 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-lock-open-icon lucide-lock-open"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail-icon lucide-mail"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"/><rect x="2" y="4" width="20" height="16" rx="2"/></svg>

After

Width:  |  Height:  |  Size: 332 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-check-icon lucide-map-pin-check"><path d="M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728"/><circle cx="12" cy="10" r="3"/><path d="m16 18 2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 457 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-message-circle-question-mark-icon lucide-message-circle-question-mark"><path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>

After

Width:  |  Height:  |  Size: 485 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pocket-knife-icon lucide-pocket-knife"><path d="M3 2v1c0 1 2 1 2 2S3 6 3 7s2 1 2 2-2 1-2 2 2 1 2 2"/><path d="M18 6h.01"/><path d="M6 18h.01"/><path d="M20.83 8.83a4 4 0 0 0-5.66-5.66l-12 12a4 4 0 1 0 5.66 5.66Z"/><path d="M18 11.66V22a4 4 0 0 0 4-4V6"/></svg>

After

Width:  |  Height:  |  Size: 463 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>

After

Width:  |  Height:  |  Size: 325 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-route-icon lucide-route"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg>

After

Width:  |  Height:  |  Size: 358 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save-icon lucide-save"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>

After

Width:  |  Height:  |  Size: 429 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>

After

Width:  |  Height:  |  Size: 610 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::AreasController < ApiController
before_action :set_area, only: %i[update destroy]
before_action :set_area, only: %i[show update destroy]
def index
@areas = current_api_user.areas
@ -9,6 +9,10 @@ class Api::V1::AreasController < ApiController
render json: @areas, status: :ok
end
def show
render json: @area, status: :ok
end
def create
@area = current_api_user.areas.build(area_params)

View file

@ -1,14 +1,17 @@
# frozen_string_literal: true
class Api::V1::Countries::VisitedCitiesController < ApiController
include SafeTimestampParser
before_action :validate_params
def index
start_at = DateTime.parse(params[:start_at]).to_i
end_at = DateTime.parse(params[:end_at]).to_i
start_at = safe_timestamp(params[:start_at])
end_at = safe_timestamp(params[:end_at])
points = current_api_user
.points
.without_raw_data
.where(timestamp: start_at..end_at)
render json: { data: CountriesAndCities.new(points).call }

View file

@ -1,10 +1,10 @@
# frozen_string_literal: true
class Api::V1::FamiliesController < ApiController
class Api::V1::Families::LocationsController < ApiController
before_action :ensure_family_feature_enabled!
before_action :ensure_user_in_family!
def locations
def index
family_locations = Families::Locations.new(current_api_user).call
render json: {
@ -17,7 +17,7 @@ class Api::V1::FamiliesController < ApiController
private
def ensure_user_in_family!
return if current_api_user.in_family?
return if current_api_user&.in_family?
render json: { error: 'User is not part of a family' }, status: :forbidden
end

View file

@ -0,0 +1,138 @@
# frozen_string_literal: true
module Api
module V1
class PlacesController < ApiController
before_action :set_place, only: [:show, :update, :destroy]
def index
@places = current_api_user.places.includes(:tags, :visits)
if params[:tag_ids].present?
tag_ids = Array(params[:tag_ids])
# Separate numeric tag IDs from "untagged"
numeric_tag_ids = tag_ids.reject { |id| id == 'untagged' }.map(&:to_i)
include_untagged = tag_ids.include?('untagged')
if numeric_tag_ids.any? && include_untagged
# Both tagged and untagged: return union (OR logic)
tagged = current_api_user.places.includes(:tags, :visits).with_tags(numeric_tag_ids)
untagged = current_api_user.places.includes(:tags, :visits).without_tags
@places = Place.from("(#{tagged.to_sql} UNION #{untagged.to_sql}) AS places")
.includes(:tags, :visits)
elsif numeric_tag_ids.any?
# Only tagged places with ANY of the selected tags (OR logic)
@places = @places.with_tags(numeric_tag_ids)
elsif include_untagged
# Only untagged places
@places = @places.without_tags
end
end
render json: @places.map { |place| serialize_place(place) }
end
def show
render json: serialize_place(@place)
end
def create
@place = current_api_user.places.build(place_params.except(:tag_ids))
if @place.save
add_tags if tag_ids.present?
@place = current_api_user.places.includes(:tags, :visits).find(@place.id)
render json: serialize_place(@place), status: :created
else
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @place.update(place_params)
set_tags if params[:place][:tag_ids]
@place = current_api_user.places.includes(:tags, :visits).find(@place.id)
render json: serialize_place(@place)
else
render json: { errors: @place.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@place.destroy!
head :no_content
end
def nearby
unless params[:latitude].present? && params[:longitude].present?
return render json: { error: 'latitude and longitude are required' }, status: :bad_request
end
results = Places::NearbySearch.new(
latitude: params[:latitude].to_f,
longitude: params[:longitude].to_f,
radius: params[:radius]&.to_f || 0.5,
limit: params[:limit]&.to_i || 10
).call
render json: { places: results }
end
private
def set_place
@place = current_api_user.places.includes(:tags, :visits).find(params[:id])
end
def place_params
params.require(:place).permit(:name, :latitude, :longitude, :source, :note, tag_ids: [])
end
def tag_ids
ids = params.dig(:place, :tag_ids)
Array(ids).compact
end
def add_tags
return if tag_ids.empty?
tags = current_api_user.tags.where(id: tag_ids)
@place.tags << tags
end
def set_tags
tag_ids_param = Array(params.dig(:place, :tag_ids)).compact
tags = current_api_user.tags.where(id: tag_ids_param)
@place.tags = tags
end
def serialize_place(place)
{
id: place.id,
name: place.name,
latitude: place.lat,
longitude: place.lon,
source: place.source,
note: place.note,
icon: place.tags.first&.icon,
color: place.tags.first&.color,
visits_count: place.visits.count,
created_at: place.created_at,
tags: place.tags.map do |tag|
{
id: tag.id,
name: tag.name,
icon: tag.icon,
color: tag.color,
privacy_radius_meters: tag.privacy_radius_meters
}
end
}
end
end
end
end

View file

@ -1,17 +1,37 @@
# frozen_string_literal: true
class Api::V1::PointsController < ApiController
include SafeTimestampParser
before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy]
before_action :validate_points_limit, only: %i[create]
def index
start_at = params[:start_at]&.to_datetime&.to_i
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i
start_at = params[:start_at].present? ? safe_timestamp(params[:start_at]) : nil
end_at = params[:end_at].present? ? safe_timestamp(params[:end_at]) : Time.zone.now.to_i
order = params[:order] || 'desc'
points = current_api_user
.points
.without_raw_data
.where(timestamp: start_at..end_at)
# Filter by geographic bounds if provided
if params[:min_longitude].present? && params[:max_longitude].present? &&
params[:min_latitude].present? && params[:max_latitude].present?
min_lng = params[:min_longitude].to_f
max_lng = params[:max_longitude].to_f
min_lat = params[:min_latitude].to_f
max_lat = params[:max_latitude].to_f
# Use PostGIS to filter points within bounding box
points = points.where(
'ST_X(lonlat::geometry) BETWEEN ? AND ? AND ST_Y(lonlat::geometry) BETWEEN ? AND ?',
min_lng, max_lng, min_lat, max_lat
)
end
points = points
.order(timestamp: order)
.page(params[:page])
.per(params[:per_page] || 100)

View file

@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
def index
render json: {
settings: current_api_user.safe_settings,
settings: current_api_user.safe_settings.config,
status: 'success'
}, status: :ok
end
@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController
settings_params.each { |key, value| current_api_user.settings[key] = value }
if current_api_user.save
render json: { message: 'Settings updated', settings: current_api_user.settings, status: 'success' },
render json: { message: 'Settings updated', settings: current_api_user.safe_settings.config, status: 'success' },
status: :ok
else
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
@ -31,6 +31,7 @@ class Api::V1::SettingsController < ApiController
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
:maps_v2_style, :maps_maplibre_style, :globe_projection,
enabled_map_layers: []
)
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Api
module V1
class TagsController < ApiController
def privacy_zones
zones = current_api_user.tags.privacy_zones.includes(:places)
render json: zones.map { |tag| TagSerializer.new(tag).call }
end
end
end
end

View file

@ -10,6 +10,11 @@ class Api::V1::VisitsController < ApiController
render json: serialized_visits
end
def show
visit = current_api_user.visits.find(params[:id])
render json: Api::VisitSerializer.new(visit).call
end
def create
service = Visits::Create.new(current_api_user, visit_params)

View file

@ -5,8 +5,14 @@ class ApiController < ApplicationController
before_action :set_version_header
before_action :authenticate_api_key
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
private
def record_not_found
render json: { error: 'Record not found' }, status: :not_found
end
def set_version_header
message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!"

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
module SafeTimestampParser
extend ActiveSupport::Concern
private
def safe_timestamp(date_string)
return Time.zone.now.to_i if date_string.blank?
parsed_time = Time.zone.parse(date_string)
# Time.zone.parse returns epoch time (2000-01-01) for unparseable strings
# Check if it's a valid parse by seeing if year is suspiciously at epoch
return Time.zone.now.to_i if parsed_time.nil? || (parsed_time.year == 2000 && !date_string.include?('2000'))
min_timestamp = Time.zone.parse('1970-01-01').to_i
max_timestamp = Time.zone.parse('2100-01-01').to_i
parsed_time.to_i.clamp(min_timestamp, max_timestamp)
rescue ArgumentError, TypeError
Time.zone.now.to_i
end
end

View file

@ -7,7 +7,7 @@ class ExportsController < ApplicationController
before_action :set_export, only: %i[destroy]
def index
@exports = current_user.exports.order(created_at: :desc).page(params[:page])
@exports = current_user.exports.with_attached_file.order(created_at: :desc).page(params[:page])
end
def create

View file

@ -3,7 +3,7 @@
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]
before_action :set_family, only: %i[show edit update destroy]
def show
authorize @family
@ -76,16 +76,6 @@ class FamiliesController < ApplicationController
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

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Family::LocationSharingController < ApplicationController
before_action :authenticate_user!
before_action :ensure_family_feature_enabled!
before_action :ensure_user_in_family!
def update
result = Families::UpdateLocationSharing.new(
user: current_user,
enabled: params[:enabled],
duration: params[:duration]
).call
render json: result.payload, status: result.status
end
private
def ensure_user_in_family!
return if current_user.in_family?
render json: { error: 'User is not part of a family' }, status: :forbidden
end
end

View file

@ -1,10 +1,12 @@
# frozen_string_literal: true
class HomeController < ApplicationController
include ApplicationHelper
def index
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED
redirect_to map_url if current_user
redirect_to preferred_map_path if current_user
@points = current_user.points.without_raw_data if current_user
end

View file

@ -14,6 +14,7 @@ class ImportsController < ApplicationController
def index
@imports = policy_scope(Import)
.select(:id, :name, :source, :created_at, :processed, :status)
.with_attached_file
.order(created_at: :desc)
.page(params[:page])
end
@ -78,9 +79,13 @@ class ImportsController < ApplicationController
end
def destroy
Imports::Destroy.new(current_user, @import).call
@import.deleting!
Imports::DestroyJob.perform_later(@import.id)
redirect_to imports_url, notice: 'Import was successfully destroyed.', status: :see_other
respond_to do |format|
format.html { redirect_to imports_url, notice: 'Import is being deleted.', status: :see_other }
format.turbo_stream
end
end
private

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class MapController < ApplicationController
class Map::LeafletController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user!
layout 'map', only: :index
@ -14,6 +16,7 @@ class MapController < ApplicationController
@years = years_range
@points_number = points_count
@features = DawarichSettings.features
@home_coordinates = current_user.home_place_coordinates
end
private
@ -70,14 +73,14 @@ class MapController < ApplicationController
end
def start_at
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
return safe_timestamp(params[:start_at]) if params[:start_at].present?
return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any?
Time.zone.today.beginning_of_day.to_i
end
def end_at
return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present?
return safe_timestamp(params[:end_at]) if params[:end_at].present?
return Time.zone.at(points.last.timestamp).end_of_day.to_i if points.any?
Time.zone.today.end_of_day.to_i

View file

@ -0,0 +1,35 @@
module Map
class MaplibreController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user!
layout 'map'
def index
@start_at = parsed_start_at
@end_at = parsed_end_at
end
private
def start_at
return safe_timestamp(params[:start_at]) if params[:start_at].present?
Time.zone.today.beginning_of_day.to_i
end
def end_at
return safe_timestamp(params[:end_at]) if params[:end_at].present?
Time.zone.today.end_of_day.to_i
end
def parsed_start_at
Time.zone.at(start_at)
end
def parsed_end_at
Time.zone.at(end_at)
end
end
end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class PointsController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user!
def index
@ -40,13 +42,13 @@ class PointsController < ApplicationController
def start_at
return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil?
Time.zone.parse(params[:start_at]).to_i
safe_timestamp(params[:start_at])
end
def end_at
return Time.zone.today.end_of_day.to_i if params[:end_at].nil?
Time.zone.parse(params[:end_at]).to_i
safe_timestamp(params[:end_at])
end
def points

View file

@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController
private
def settings_params
params.require(:maps).permit(:name, :url, :distance_unit)
params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
end
end

View file

@ -35,7 +35,7 @@ class SettingsController < ApplicationController
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:visits_suggestions_enabled
:visits_suggestions_enabled, :digest_emails_enabled
)
end
end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
class Shared::DigestsController < ApplicationController
helper Users::DigestsHelper
helper CountryFlagHelper
before_action :authenticate_user!, except: [:show]
before_action :authenticate_active_user!, only: [:update]
def show
@digest = Users::Digest.find_by(sharing_uuid: params[:uuid])
unless @digest&.public_accessible?
return redirect_to root_path,
alert: 'Shared digest not found or no longer available'
end
@year = @digest.year
@user = @digest.user
@distance_unit = @user.safe_settings.distance_unit || 'km'
@is_public_view = true
render 'users/digests/public_year'
end
def update
@year = params[:year].to_i
@digest = current_user.digests.yearly.find_by(year: @year)
return head :not_found unless @digest
if params[:enabled] == '1'
@digest.enable_sharing!(expiration: params[:expiration] || '24h')
sharing_url = shared_users_digest_url(@digest.sharing_uuid)
render json: {
success: true,
sharing_url: sharing_url,
message: 'Sharing enabled successfully'
}
else
@digest.disable_sharing!
render json: {
success: true,
message: 'Sharing disabled successfully'
}
end
rescue StandardError
render json: {
success: false,
message: 'Failed to update sharing settings'
}, status: :unprocessable_content
end
end

View file

@ -0,0 +1,62 @@
# frozen_string_literal: true
class TagsController < ApplicationController
before_action :authenticate_user!
before_action :set_tag, only: [:edit, :update, :destroy]
def index
@tags = policy_scope(Tag).ordered
authorize Tag
end
def new
@tag = current_user.tags.build
authorize @tag
end
def create
@tag = current_user.tags.build(tag_params)
authorize @tag
if @tag.save
redirect_to tags_path, notice: 'Tag was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
def edit
authorize @tag
end
def update
authorize @tag
if @tag.update(tag_params)
redirect_to tags_path, notice: 'Tag was successfully updated.'
else
render :edit, status: :unprocessable_entity
end
end
def destroy
authorize @tag
@tag.destroy!
redirect_to tags_path, notice: 'Tag was successfully deleted.', status: :see_other
end
private
def set_tag
@tag = current_user.tags.find(params[:id])
end
def tag_params
params.require(:tag).permit(:name, :icon, :color, :privacy_radius_meters)
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
class Users::DigestsController < ApplicationController
helper Users::DigestsHelper
helper CountryFlagHelper
before_action :authenticate_user!
before_action :authenticate_active_user!, only: [:create]
before_action :set_digest, only: %i[show destroy]
def index
@digests = current_user.digests.yearly.order(year: :desc)
@available_years = available_years_for_generation
end
def show
@distance_unit = current_user.safe_settings.distance_unit || 'km'
end
def create
year = params[:year].to_i
if valid_year?(year)
Users::Digests::CalculatingJob.perform_later(current_user.id, year)
redirect_to users_digests_path,
notice: "Year-end digest for #{year} is being generated. Check back soon!",
status: :see_other
else
redirect_to users_digests_path, alert: 'Invalid year selected', status: :see_other
end
end
def destroy
year = @digest.year
@digest.destroy!
redirect_to users_digests_path, notice: "Year-end digest for #{year} has been deleted", status: :see_other
end
private
def set_digest
@digest = current_user.digests.yearly.find_by!(year: params[:year])
rescue ActiveRecord::RecordNotFound
redirect_to users_digests_path, alert: 'Digest not found'
end
def available_years_for_generation
tracked_years = current_user.stats.select(:year).distinct.pluck(:year)
existing_digests = current_user.digests.yearly.pluck(:year)
(tracked_years - existing_digests - [Time.current.year]).sort.reverse
end
def valid_year?(year)
return false if year < 2000 || year > Time.current.year
current_user.stats.exists?(year: year)
end
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def github
handle_auth('GitHub')
end
def google_oauth2
handle_auth('Google')
end
def openid_connect
handle_auth('OpenID Connect')
end
def failure
error_type = request.env['omniauth.error.type']
error = request.env['omniauth.error']
# Provide user-friendly error messages
error_message =
case error_type
when :invalid_credentials
'Invalid credentials. Please check your username and password.'
when :timeout
'Connection timeout. Please try again.'
when :csrf_detected
'Security error detected. Please try again.'
else
if error&.message&.include?('Discovery')
'Unable to connect to authentication provider. Please contact your administrator.'
elsif error&.message&.include?('Issuer mismatch')
'Authentication provider configuration error. Please contact your administrator.'
else
"Authentication failed: #{params[:message] || error&.message || 'Unknown error'}"
end
end
redirect_to root_path, alert: error_message
end
private
def handle_auth(provider)
@user = User.from_omniauth(request.env['omniauth.auth'])
if @user&.persisted?
flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider
sign_in_and_redirect @user, event: :authentication
elsif @user.nil?
# User creation was rejected (e.g., OIDC auto-register disabled)
error_message = if provider == 'OpenID Connect' && !oidc_auto_register_enabled?
'Your account must be created by an administrator before you can sign in with OIDC. ' \
'Please contact your administrator.'
else
'Unable to create your account. Please try again or contact support.'
end
redirect_to root_path, alert: error_message
else
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n")
end
end
def oidc_auto_register_enabled?
OIDC_AUTO_REGISTER
end
end

View file

@ -45,6 +45,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
def check_registration_allowed
return unless self_hosted_mode?
return if valid_invitation_token?
return if email_password_registration_allowed?
redirect_to root_path,
alert: 'Registration is not available. Please contact your administrator for access.'
@ -96,4 +97,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
def sign_up_params
super
end
def email_password_registration_allowed?
ALLOW_EMAIL_PASSWORD_REGISTRATION
end
end

View file

@ -130,4 +130,23 @@ module ApplicationHelper
'btn-success'
end
end
def oauth_provider_name(provider)
return OIDC_PROVIDER_NAME if provider == :openid_connect
OmniAuth::Utils.camelize(provider)
end
def email_password_registration_enabled?
return true unless DawarichSettings.self_hosted?
ALLOW_EMAIL_PASSWORD_REGISTRATION
end
def preferred_map_path
return map_v1_path unless user_signed_in?
preferred_version = current_user.safe_settings.maps&.dig('preferred_version')
preferred_version == 'v2' ? map_v2_path : map_v1_path
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
module TagsHelper
COMMON_TAG_EMOJIS = %w[
🏠 🏢 🏫 🏥 🏪 🏨 🏦 🏛 🏟 🏖
🕌 🕍 🗼 🗽 🗿 💒 🏰 🏯
🍕 🍔 🍟 🍣 🍱 🍜 🍝 🍛 🥘 🍲
🍺 🍷 🥂 🍹 🍸 🥃 🍻 🥤 🧃
🏃 🏀 🏈 🎾 🏐 🏓 🏸 🏒
🚗 🚕 🚙 🚌 🚎 🏎 🚓 🚑 🚒 🚐
🚁 🚤 🛥 🚂 🚆 🚇 🚊
🎭 🎪 🎨 🎬 🎤 🎧 🎼 🎹 🎸 🎺
📚 📖 🖊 📝 📋 📌 📍 🗺 🧭
💼 👔 🎓 🏆 🎯 🎲 🎮 🎰 🛍 💍
].freeze
def random_tag_emoji
COMMON_TAG_EMOJIS.sample
end
end

View file

@ -0,0 +1,71 @@
# frozen_string_literal: true
module Users
module DigestsHelper
PROGRESS_COLORS = %w[
progress-primary progress-secondary progress-accent
progress-info progress-success progress-warning
].freeze
def progress_color_for_index(index)
PROGRESS_COLORS[index % PROGRESS_COLORS.length]
end
def city_progress_value(city_count, max_cities)
return 0 unless max_cities&.positive?
(city_count.to_f / max_cities * 100).round
end
def max_cities_count(toponyms)
return 0 if toponyms.blank?
toponyms.map { |country| country['cities']&.length || 0 }.max
end
def distance_with_unit(distance_meters, unit)
value = Users::Digest.convert_distance(distance_meters, unit).round
"#{number_with_delimiter(value)} #{unit}"
end
def distance_comparison_text(distance_meters)
distance_km = distance_meters.to_f / 1000
if distance_km >= Users::Digest::MOON_DISTANCE_KM
percentage = ((distance_km / Users::Digest::MOON_DISTANCE_KM) * 100).round(1)
"That's #{percentage}% of the distance to the Moon!"
else
percentage = ((distance_km / Users::Digest::EARTH_CIRCUMFERENCE_KM) * 100).round(1)
"That's #{percentage}% of Earth's circumference!"
end
end
def format_time_spent(minutes)
return "#{minutes} minutes" if minutes < 60
hours = minutes / 60
remaining_minutes = minutes % 60
if hours < 24
"#{hours}h #{remaining_minutes}m"
else
days = hours / 24
remaining_hours = hours % 24
"#{days}d #{remaining_hours}h"
end
end
def yoy_change_class(change)
return '' if change.nil?
change.negative? ? 'negative' : 'positive'
end
def yoy_change_text(change)
return '' if change.nil?
prefix = change.positive? ? '+' : ''
"#{prefix}#{change}%"
end
end
end

724
app/javascript/README.md Normal file
View file

@ -0,0 +1,724 @@
# Dawarich JavaScript Architecture
This document provides a comprehensive guide to the JavaScript architecture used in the Dawarich application, with a focus on the Maps (MapLibre) implementation.
## Table of Contents
- [Overview](#overview)
- [Technology Stack](#technology-stack)
- [Architecture Patterns](#architecture-patterns)
- [Directory Structure](#directory-structure)
- [Core Concepts](#core-concepts)
- [Maps (MapLibre) Architecture](#maps-maplibre-architecture)
- [Creating New Features](#creating-new-features)
- [Best Practices](#best-practices)
## Overview
Dawarich uses a modern JavaScript architecture built on **Hotwire (Turbo + Stimulus)** for page interactions and **MapLibre GL JS** for map rendering. The Maps (MapLibre) implementation follows object-oriented principles with clear separation of concerns.
## Technology Stack
- **Stimulus** - Modest JavaScript framework for sprinkles of interactivity
- **Turbo Rails** - SPA-like page navigation without building an SPA
- **MapLibre GL JS** - Open-source map rendering engine
- **ES6 Modules** - Modern JavaScript module system
- **Tailwind CSS + DaisyUI** - Utility-first CSS framework
## Architecture Patterns
### 1. Stimulus Controllers
**Purpose:** Connect DOM elements to JavaScript behavior
**Location:** `app/javascript/controllers/`
**Pattern:**
```javascript
import { Controller } from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['element']
static values = { apiKey: String }
connect() {
// Initialize when element appears in DOM
}
disconnect() {
// Cleanup when element is removed
}
}
```
**Key Principles:**
- Controllers should be stateless when possible
- Use `targets` for DOM element references
- Use `values` for passing data from HTML
- Always cleanup in `disconnect()`
### 2. Service Classes
**Purpose:** Encapsulate business logic and API communication
**Location:** `app/javascript/maps_maplibre/services/`
**Pattern:**
```javascript
export class ApiClient {
constructor(apiKey) {
this.apiKey = apiKey
}
async fetchData() {
const response = await fetch(url, {
headers: this.getHeaders()
})
return response.json()
}
}
```
**Key Principles:**
- Single responsibility - one service per concern
- Consistent error handling
- Return promises for async operations
- Use constructor injection for dependencies
### 3. Layer Classes (Map Layers)
**Purpose:** Manage map visualization layers
**Location:** `app/javascript/maps_maplibre/layers/`
**Pattern:**
```javascript
import { BaseLayer } from './base_layer'
export class CustomLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'custom', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data
}
}
getLayerConfigs() {
return [{
id: this.id,
type: 'circle',
source: this.sourceId,
paint: { /* ... */ }
}]
}
}
```
**Key Principles:**
- All layers extend `BaseLayer`
- Implement `getSourceConfig()` and `getLayerConfigs()`
- Store data in `this.data`
- Use `this.visible` for visibility state
- Inherit common methods: `add()`, `update()`, `show()`, `hide()`, `toggle()`
### 4. Utility Modules
**Purpose:** Provide reusable helper functions
**Location:** `app/javascript/maps_maplibre/utils/`
**Pattern:**
```javascript
export class UtilityClass {
static helperMethod(param) {
// Static methods for stateless utilities
}
}
// Or singleton pattern
export const utilityInstance = new UtilityClass()
```
### 5. Component Classes
**Purpose:** Reusable UI components
**Location:** `app/javascript/maps_maplibre/components/`
**Pattern:**
```javascript
export class PopupFactory {
static createPopup(data) {
return `<div>${data.name}</div>`
}
}
```
## Directory Structure
```
app/javascript/
├── application.js # Entry point
├── controllers/ # Stimulus controllers
│ ├── maps/maplibre_controller.js # Main map controller
│ ├── maps_maplibre/ # Controller modules
│ │ ├── layer_manager.js # Layer lifecycle management
│ │ ├── data_loader.js # API data fetching
│ │ ├── event_handlers.js # Map event handling
│ │ ├── filter_manager.js # Data filtering
│ │ └── date_manager.js # Date range management
│ └── ... # Other controllers
├── maps_maplibre/ # Maps (MapLibre) implementation
│ ├── layers/ # Map layer classes
│ │ ├── base_layer.js # Abstract base class
│ │ ├── points_layer.js # Point markers
│ │ ├── routes_layer.js # Route lines
│ │ ├── heatmap_layer.js # Heatmap visualization
│ │ ├── visits_layer.js # Visit markers
│ │ ├── photos_layer.js # Photo markers
│ │ ├── places_layer.js # Places markers
│ │ ├── areas_layer.js # User-defined areas
│ │ ├── fog_layer.js # Fog of war overlay
│ │ └── scratch_layer.js # Scratch map
│ ├── services/ # API and external services
│ │ ├── api_client.js # REST API wrapper
│ │ └── location_search_service.js
│ ├── utils/ # Helper utilities
│ │ ├── settings_manager.js # User preferences
│ │ ├── geojson_transformers.js
│ │ ├── performance_monitor.js
│ │ ├── lazy_loader.js # Code splitting
│ │ └── ...
│ ├── components/ # Reusable UI components
│ │ ├── popup_factory.js # Map popup generator
│ │ ├── toast.js # Toast notifications
│ │ └── ...
│ └── channels/ # ActionCable channels
│ └── map_channel.js # Real-time updates
└── maps/ # Legacy Maps V1 (being phased out)
```
## Core Concepts
### Manager Pattern
The Maps (MapLibre) controller delegates responsibilities to specialized managers:
1. **LayerManager** - Layer lifecycle (add/remove/toggle/update)
2. **DataLoader** - API data fetching and transformation
3. **EventHandlers** - Map interaction events
4. **FilterManager** - Data filtering and searching
5. **DateManager** - Date range calculations
6. **SettingsManager** - User preferences persistence
**Benefits:**
- Single Responsibility Principle
- Easier testing
- Improved code organization
- Better reusability
### Data Flow
```
User Action
Stimulus Controller Method
Manager (e.g., DataLoader)
Service (e.g., ApiClient)
API Endpoint
Transform to GeoJSON
Update Layer
MapLibre Renders
```
### State Management
**Settings Persistence:**
- Primary: Backend API (`/api/v1/settings`)
- Fallback: localStorage
- Sync on initialization
- Save on every change (debounced)
**Layer State:**
- Stored in layer instances (`this.visible`, `this.data`)
- Synced with SettingsManager
- Persisted across sessions
### Event System
**Custom Events:**
```javascript
// Dispatch
document.dispatchEvent(new CustomEvent('visit:created', {
detail: { visitId: 123 }
}))
// Listen
document.addEventListener('visit:created', (event) => {
console.log(event.detail.visitId)
})
```
**Map Events:**
```javascript
map.on('click', 'layer-id', (e) => {
const feature = e.features[0]
// Handle click
})
```
## Maps (MapLibre) Architecture
### Layer Hierarchy
Layers are rendered in specific order (bottom to top):
1. **Scratch Layer** - Visited countries/regions overlay
2. **Heatmap Layer** - Point density visualization
3. **Areas Layer** - User-defined circular areas
4. **Tracks Layer** - Imported GPS tracks
5. **Routes Layer** - Generated routes from points
6. **Visits Layer** - Detected visits to places
7. **Places Layer** - Named locations
8. **Photos Layer** - Photos with geolocation
9. **Family Layer** - Real-time family member locations
10. **Points Layer** - Individual location points
11. **Fog Layer** - Canvas overlay showing unexplored areas
### BaseLayer Pattern
All layers extend `BaseLayer` which provides:
**Methods:**
- `add(data)` - Add layer to map
- `update(data)` - Update layer data
- `remove()` - Remove layer from map
- `show()` / `hide()` - Toggle visibility
- `toggle(visible)` - Set visibility state
**Abstract Methods (must implement):**
- `getSourceConfig()` - MapLibre source configuration
- `getLayerConfigs()` - Array of MapLibre layer configurations
**Example Implementation:**
```javascript
export class PointsLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'points', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || { type: 'FeatureCollection', features: [] }
}
}
getLayerConfigs() {
return [{
id: 'points',
type: 'circle',
source: this.sourceId,
paint: {
'circle-radius': 4,
'circle-color': '#3b82f6'
}
}]
}
}
```
### Lazy Loading
Heavy layers are lazy-loaded to reduce initial bundle size:
```javascript
// In lazy_loader.js
const paths = {
'fog': () => import('../layers/fog_layer.js'),
'scratch': () => import('../layers/scratch_layer.js')
}
// Usage
const ScratchLayer = await lazyLoader.loadLayer('scratch')
const layer = new ScratchLayer(map, options)
```
**When to use:**
- Large dependencies (e.g., canvas-based rendering)
- Rarely-used features
- Heavy computations
### GeoJSON Transformations
All data is transformed to GeoJSON before rendering:
```javascript
// Points
{
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [longitude, latitude]
},
properties: {
id: 1,
timestamp: '2024-01-01T12:00:00Z',
// ... other properties
}
}]
}
```
**Key Functions:**
- `pointsToGeoJSON(points)` - Convert points array
- `visitsToGeoJSON(visits)` - Convert visits
- `photosToGeoJSON(photos)` - Convert photos
- `placesToGeoJSON(places)` - Convert places
- `areasToGeoJSON(areas)` - Convert circular areas to polygons
## Creating New Features
### Adding a New Layer
1. **Create layer class** in `app/javascript/maps_maplibre/layers/`:
```javascript
import { BaseLayer } from './base_layer'
export class NewLayer extends BaseLayer {
constructor(map, options = {}) {
super(map, { id: 'new-layer', ...options })
}
getSourceConfig() {
return {
type: 'geojson',
data: this.data || { type: 'FeatureCollection', features: [] }
}
}
getLayerConfigs() {
return [{
id: this.id,
type: 'symbol', // or 'circle', 'line', 'fill', 'heatmap'
source: this.sourceId,
paint: { /* styling */ },
layout: { /* layout */ }
}]
}
}
```
2. **Register in LayerManager** (`controllers/maps_maplibre/layer_manager.js`):
```javascript
import { NewLayer } from 'maps_maplibre/layers/new_layer'
// In addAllLayers method
_addNewLayer(dataGeoJSON) {
if (!this.layers.newLayer) {
this.layers.newLayer = new NewLayer(this.map, {
visible: this.settings.newLayerEnabled || false
})
this.layers.newLayer.add(dataGeoJSON)
} else {
this.layers.newLayer.update(dataGeoJSON)
}
}
```
3. **Add to settings** (`utils/settings_manager.js`):
```javascript
const DEFAULT_SETTINGS = {
// ...
newLayerEnabled: false
}
const LAYER_NAME_MAP = {
// ...
'New Layer': 'newLayerEnabled'
}
```
4. **Add UI controls** in view template.
### Adding a New API Endpoint
1. **Add method to ApiClient** (`services/api_client.js`):
```javascript
async fetchNewData({ param1, param2 }) {
const params = new URLSearchParams({ param1, param2 })
const response = await fetch(`${this.baseURL}/new-endpoint?${params}`, {
headers: this.getHeaders()
})
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.statusText}`)
}
return response.json()
}
```
2. **Add transformation** in DataLoader:
```javascript
newDataToGeoJSON(data) {
return {
type: 'FeatureCollection',
features: data.map(item => ({
type: 'Feature',
geometry: { /* ... */ },
properties: { /* ... */ }
}))
}
}
```
3. **Use in controller:**
```javascript
const data = await this.api.fetchNewData({ param1, param2 })
const geojson = this.dataLoader.newDataToGeoJSON(data)
this.layerManager.updateLayer('new-layer', geojson)
```
### Adding a New Utility
1. **Create utility file** in `utils/`:
```javascript
export class NewUtility {
static calculate(input) {
// Pure function - no side effects
return result
}
}
// Or singleton for stateful utilities
class NewManager {
constructor() {
this.state = {}
}
doSomething() {
// Stateful operation
}
}
export const newManager = new NewManager()
```
2. **Import and use:**
```javascript
import { NewUtility } from 'maps_maplibre/utils/new_utility'
const result = NewUtility.calculate(input)
```
## Best Practices
### Code Style
1. **Use ES6+ features:**
- Arrow functions
- Template literals
- Destructuring
- Async/await
- Classes
2. **Naming conventions:**
- Classes: `PascalCase`
- Methods/variables: `camelCase`
- Constants: `UPPER_SNAKE_CASE`
- Files: `snake_case.js`
3. **Always use semicolons** for statement termination
4. **Prefer `const` over `let`**, avoid `var`
### Performance
1. **Lazy load heavy features:**
```javascript
const Layer = await lazyLoader.loadLayer('name')
```
2. **Debounce frequent operations:**
```javascript
let timeout
function onInput(e) {
clearTimeout(timeout)
timeout = setTimeout(() => actualWork(e), 300)
}
```
3. **Use performance monitoring:**
```javascript
performanceMonitor.mark('operation')
// ... do work
performanceMonitor.measure('operation')
```
4. **Minimize DOM manipulations** - batch updates when possible
### Error Handling
1. **Always handle promise rejections:**
```javascript
try {
const data = await fetchData()
} catch (error) {
console.error('Failed:', error)
Toast.error('Operation failed')
}
```
2. **Provide user feedback:**
```javascript
Toast.success('Data loaded')
Toast.error('Failed to load data')
Toast.info('Click map to add point')
```
3. **Log errors for debugging:**
```javascript
console.error('[Component] Error details:', error)
```
### Memory Management
1. **Always cleanup in disconnect():**
```javascript
disconnect() {
this.searchManager?.destroy()
this.cleanup.cleanup()
this.map?.remove()
}
```
2. **Use CleanupHelper for event listeners:**
```javascript
this.cleanup = new CleanupHelper()
this.cleanup.addEventListener(element, 'click', handler)
// In disconnect():
this.cleanup.cleanup() // Removes all listeners
```
3. **Remove map layers and sources:**
```javascript
remove() {
this.getLayerIds().forEach(id => {
if (this.map.getLayer(id)) {
this.map.removeLayer(id)
}
})
if (this.map.getSource(this.sourceId)) {
this.map.removeSource(this.sourceId)
}
}
```
### Testing Considerations
1. **Keep methods small and focused** - easier to test
2. **Avoid tight coupling** - use dependency injection
3. **Separate pure functions** from side effects
4. **Use static methods** for stateless utilities
### State Management
1. **Single source of truth:**
- Settings: `SettingsManager`
- Layer data: Layer instances
- UI state: Controller properties
2. **Sync state with backend:**
```javascript
SettingsManager.updateSetting('key', value)
// Saves to both localStorage and backend
```
3. **Restore state on load:**
```javascript
async connect() {
this.settings = await SettingsManager.sync()
this.syncToggleStates()
}
```
### Documentation
1. **Add JSDoc comments for public APIs:**
```javascript
/**
* Fetch all points for date range
* @param {Object} options - { start_at, end_at, onProgress }
* @returns {Promise<Array>} All points
*/
async fetchAllPoints({ start_at, end_at, onProgress }) {
// ...
}
```
2. **Document complex logic with inline comments**
3. **Keep this README updated** when adding major features
### Code Organization
1. **One class per file** - easier to find and maintain
2. **Group related functionality** in directories
3. **Use index files** for barrel exports when needed
4. **Avoid circular dependencies** - use dependency injection
### Migration from Maps V1 to V2
When updating features, follow this pattern:
1. **Keep V1 working** - V2 is opt-in
2. **Share utilities** where possible (e.g., color calculations)
3. **Use same API endpoints** - maintain compatibility
4. **Document differences** in code comments
---
## Examples
### Complete Layer Implementation
See `app/javascript/maps_maplibre/layers/heatmap_layer.js` for a simple example.
### Complete Utility Implementation
See `app/javascript/maps_maplibre/utils/settings_manager.js` for state management.
### Complete Service Implementation
See `app/javascript/maps_maplibre/services/api_client.js` for API communication.
### Complete Controller Implementation
See `app/javascript/controllers/maps/maplibre_controller.js` for orchestration.
---
**Questions or need help?** Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8

View file

@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus'
/**
* Area creation controller
* Handles the area creation modal and form submission
*/
export default class extends Controller {
static targets = [
'modal',
'form',
'nameInput',
'latitudeInput',
'longitudeInput',
'radiusInput',
'radiusDisplay',
'submitButton',
'submitSpinner',
'submitText'
]
static values = {
apiKey: String
}
connect() {
this.area = null
this.setupEventListeners()
console.log('[Area Creation V2] Controller connected')
}
/**
* Setup event listeners for area drawing
*/
setupEventListeners() {
document.addEventListener('area:drawn', (e) => {
this.open(e.detail.center, e.detail.radius)
})
}
/**
* Open the modal with area data
*/
open(center, radius) {
// Store area data
this.area = { center, radius }
// Update form fields
this.latitudeInputTarget.value = center[1]
this.longitudeInputTarget.value = center[0]
this.radiusInputTarget.value = Math.round(radius)
this.radiusDisplayTarget.textContent = Math.round(radius)
// Show modal
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
}
/**
* Close the modal
*/
close() {
this.modalTarget.classList.remove('modal-open')
this.resetForm()
}
/**
* Submit the form
*/
async submit(event) {
event.preventDefault()
if (!this.area) {
console.error('No area data available')
return
}
const formData = new FormData(this.formTarget)
const name = formData.get('name')
const latitude = parseFloat(formData.get('latitude'))
const longitude = parseFloat(formData.get('longitude'))
const radius = parseFloat(formData.get('radius'))
if (!name || !latitude || !longitude || !radius) {
alert('Please fill in all required fields')
return
}
this.setLoading(true)
try {
const response = await fetch('/api/v1/areas', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`
},
body: JSON.stringify({
name,
latitude,
longitude,
radius
})
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.message || 'Failed to create area')
}
const area = await response.json()
// Close modal
this.close()
// Dispatch document event for area created
document.dispatchEvent(new CustomEvent('area:created', {
detail: { area }
}))
} catch (error) {
console.error('Error creating area:', error)
alert(`Error creating area: ${error.message}`)
} finally {
this.setLoading(false)
}
}
/**
* Set loading state
*/
setLoading(loading) {
this.submitButtonTarget.disabled = loading
if (loading) {
this.submitSpinnerTarget.classList.remove('hidden')
this.submitTextTarget.textContent = 'Creating...'
} else {
this.submitSpinnerTarget.classList.add('hidden')
this.submitTextTarget.textContent = 'Create Area'
}
}
/**
* Reset form
*/
resetForm() {
this.formTarget.reset()
this.area = null
this.radiusDisplayTarget.textContent = '0'
}
/**
* Show success message
*/
showSuccess(message) {
// Try to use the Toast component if available
if (window.Toast) {
window.Toast.show(message, 'success')
}
}
}

View file

@ -0,0 +1,146 @@
import { Controller } from '@hotwired/stimulus'
import { createCircle, calculateDistance } from 'maps_maplibre/utils/geometry'
/**
* Area drawer controller
* Draw circular areas on map
*/
export default class extends Controller {
connect() {
this.isDrawing = false
this.center = null
this.radius = 0
this.map = null
// Bind event handlers to maintain context
this.onClick = this.onClick.bind(this)
this.onMouseMove = this.onMouseMove.bind(this)
}
/**
* Start drawing mode
* @param {maplibregl.Map} map - The MapLibre map instance
*/
startDrawing(map) {
if (!map) {
console.error('[Area Drawer] Map instance not provided')
return
}
this.isDrawing = true
this.map = map
map.getCanvas().style.cursor = 'crosshair'
// Add temporary layer
if (!map.getSource('draw-source')) {
map.addSource('draw-source', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
})
map.addLayer({
id: 'draw-fill',
type: 'fill',
source: 'draw-source',
paint: {
'fill-color': '#22c55e',
'fill-opacity': 0.2
}
})
map.addLayer({
id: 'draw-outline',
type: 'line',
source: 'draw-source',
paint: {
'line-color': '#22c55e',
'line-width': 2
}
})
}
// Add event listeners
map.on('click', this.onClick)
map.on('mousemove', this.onMouseMove)
}
/**
* Cancel drawing mode
*/
cancelDrawing() {
if (!this.map) return
this.isDrawing = false
this.center = null
this.radius = 0
this.map.getCanvas().style.cursor = ''
// Clear drawing
const source = this.map.getSource('draw-source')
if (source) {
source.setData({ type: 'FeatureCollection', features: [] })
}
// Remove event listeners
this.map.off('click', this.onClick)
this.map.off('mousemove', this.onMouseMove)
}
/**
* Click handler
*/
onClick(e) {
if (!this.isDrawing || !this.map) return
if (!this.center) {
// First click - set center
this.center = [e.lngLat.lng, e.lngLat.lat]
} else {
// Second click - finish drawing
document.dispatchEvent(new CustomEvent('area:drawn', {
detail: {
center: this.center,
radius: this.radius
}
}))
this.cancelDrawing()
}
}
/**
* Mouse move handler
*/
onMouseMove(e) {
if (!this.isDrawing || !this.center || !this.map) return
const currentPoint = [e.lngLat.lng, e.lngLat.lat]
this.radius = calculateDistance(this.center, currentPoint)
this.updateDrawing()
}
/**
* Update drawing visualization
*/
updateDrawing() {
if (!this.center || this.radius === 0 || !this.map) return
const coordinates = createCircle(this.center, this.radius)
const source = this.map.getSource('draw-source')
if (source) {
source.setData({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [coordinates]
}
}]
})
}
}
}

View file

@ -0,0 +1,161 @@
import { Controller } from '@hotwired/stimulus'
import { createRectangle } from 'maps_maplibre/utils/geometry'
/**
* Area selector controller
* Draw rectangle selection on map
*/
export default class extends Controller {
static outlets = ['mapsV2']
connect() {
this.isSelecting = false
this.startPoint = null
this.currentPoint = null
}
/**
* Start rectangle selection mode
*/
startSelection() {
if (!this.hasMapsV2Outlet) {
console.error('Maps V2 outlet not found')
return
}
this.isSelecting = true
const map = this.mapsV2Outlet.map
map.getCanvas().style.cursor = 'crosshair'
// Add temporary layer for selection
if (!map.getSource('selection-source')) {
map.addSource('selection-source', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
})
map.addLayer({
id: 'selection-fill',
type: 'fill',
source: 'selection-source',
paint: {
'fill-color': '#3b82f6',
'fill-opacity': 0.2
}
})
map.addLayer({
id: 'selection-outline',
type: 'line',
source: 'selection-source',
paint: {
'line-color': '#3b82f6',
'line-width': 2,
'line-dasharray': [2, 2]
}
})
}
// Add event listeners
map.on('mousedown', this.onMouseDown)
map.on('mousemove', this.onMouseMove)
map.on('mouseup', this.onMouseUp)
}
/**
* Cancel selection mode
*/
cancelSelection() {
if (!this.hasMapsV2Outlet) return
this.isSelecting = false
this.startPoint = null
this.currentPoint = null
const map = this.mapsV2Outlet.map
map.getCanvas().style.cursor = ''
// Clear selection
const source = map.getSource('selection-source')
if (source) {
source.setData({ type: 'FeatureCollection', features: [] })
}
// Remove event listeners
map.off('mousedown', this.onMouseDown)
map.off('mousemove', this.onMouseMove)
map.off('mouseup', this.onMouseUp)
}
/**
* Mouse down handler
*/
onMouseDown = (e) => {
if (!this.isSelecting || !this.hasMapsV2Outlet) return
this.startPoint = [e.lngLat.lng, e.lngLat.lat]
this.mapsV2Outlet.map.dragPan.disable()
}
/**
* Mouse move handler
*/
onMouseMove = (e) => {
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
this.updateSelection()
}
/**
* Mouse up handler
*/
onMouseUp = (e) => {
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
this.mapsV2Outlet.map.dragPan.enable()
// Emit selection event
const bounds = this.getSelectionBounds()
this.dispatch('selected', { detail: { bounds } })
this.cancelSelection()
}
/**
* Update selection visualization
*/
updateSelection() {
if (!this.startPoint || !this.currentPoint || !this.hasMapsV2Outlet) return
const bounds = this.getSelectionBounds()
const rectangle = createRectangle(bounds)
const source = this.mapsV2Outlet.map.getSource('selection-source')
if (source) {
source.setData({
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: rectangle
}
}]
})
}
}
/**
* Get selection bounds
*/
getSelectionBounds() {
return {
minLng: Math.min(this.startPoint[0], this.currentPoint[0]),
minLat: Math.min(this.startPoint[1], this.currentPoint[1]),
maxLng: Math.max(this.startPoint[0], this.currentPoint[0]),
maxLat: Math.max(this.startPoint[1], this.currentPoint[1])
}
}
}

View file

@ -0,0 +1,82 @@
import { Controller } from "@hotwired/stimulus"
// Enhanced Color Picker Controller
// Based on RailsBlocks pattern: https://railsblocks.com/docs/color-picker
export default class extends Controller {
static targets = ["picker", "display", "displayText", "input", "swatch"]
static values = {
default: { type: String, default: "#6ab0a4" }
}
connect() {
// Initialize with current value
const currentColor = this.inputTarget.value || this.defaultValue
this.updateColor(currentColor, false)
}
// Handle color picker (main input) change
updateFromPicker(event) {
const color = event.target.value
this.updateColor(color)
}
// Handle swatch click
selectSwatch(event) {
event.preventDefault()
const color = event.currentTarget.dataset.color
if (color) {
this.updateColor(color)
}
}
// Update all color displays and inputs
updateColor(color, updatePicker = true) {
if (!color) return
// Update hidden input
if (this.hasInputTarget) {
this.inputTarget.value = color
}
// Update main color picker
if (updatePicker && this.hasPickerTarget) {
this.pickerTarget.value = color
}
// Update display
if (this.hasDisplayTarget) {
this.displayTarget.style.backgroundColor = color
}
// Update display text
if (this.hasDisplayTextTarget) {
this.displayTextTarget.textContent = color
}
// Update active swatch styling
this.updateActiveSwatchWithColor(color)
// Dispatch custom event
this.dispatch("change", { detail: { color } })
}
// Update which swatch appears active
updateActiveSwatchWithColor(color) {
if (!this.hasSwatchTarget) return
// Remove active state from all swatches
this.swatchTargets.forEach(swatch => {
swatch.classList.remove("ring-2", "ring-primary", "ring-offset-2")
})
// Find and activate matching swatch
const matchingSwatch = this.swatchTargets.find(
swatch => swatch.dataset.color?.toLowerCase() === color.toLowerCase()
)
if (matchingSwatch) {
matchingSwatch.classList.add("ring-2", "ring-primary", "ring-offset-2")
}
}
}

View file

@ -11,9 +11,57 @@ export default class extends BaseController {
connect() {
console.log("Datetime controller connected")
this.debounceTimer = null;
// Add validation listeners
if (this.hasStartedAtTarget && this.hasEndedAtTarget) {
// Validate on change to set validation state
this.startedAtTarget.addEventListener('change', () => this.validateDates())
this.endedAtTarget.addEventListener('change', () => this.validateDates())
// Validate on blur to set validation state
this.startedAtTarget.addEventListener('blur', () => this.validateDates())
this.endedAtTarget.addEventListener('blur', () => this.validateDates())
// Add form submit validation
const form = this.element.closest('form')
if (form) {
form.addEventListener('submit', (e) => {
if (!this.validateDates()) {
e.preventDefault()
this.endedAtTarget.reportValidity()
}
})
}
}
}
async updateCoordinates(event) {
validateDates(showPopup = false) {
const startDate = new Date(this.startedAtTarget.value)
const endDate = new Date(this.endedAtTarget.value)
// Clear any existing custom validity
this.startedAtTarget.setCustomValidity('')
this.endedAtTarget.setCustomValidity('')
// Check if both dates are valid
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return true
}
// Validate that start date is before end date
if (startDate >= endDate) {
const errorMessage = 'Start date must be earlier than end date'
this.endedAtTarget.setCustomValidity(errorMessage)
if (showPopup) {
this.endedAtTarget.reportValidity()
}
return false
}
return true
}
async updateCoordinates() {
// Clear any existing timeout
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
@ -25,6 +73,11 @@ export default class extends BaseController {
const endedAt = this.endedAtTarget.value
const apiKey = this.apiKeyTarget.value
// Validate dates before making API call (don't show popup, already shown on change)
if (!this.validateDates(false)) {
return
}
if (startedAt && endedAt) {
try {
const params = new URLSearchParams({

View file

@ -0,0 +1,180 @@
import { Controller } from "@hotwired/stimulus"
import { Picker } from "emoji-mart"
// Emoji Picker Controller
// Based on RailsBlocks pattern: https://railsblocks.com/docs/emoji-picker
export default class extends Controller {
static targets = ["input", "button", "pickerContainer"]
static values = {
autoSubmit: { type: Boolean, default: true }
}
connect() {
this.picker = null
this.setupKeyboardListeners()
}
disconnect() {
this.removePicker()
this.removeKeyboardListeners()
}
toggle(event) {
event.preventDefault()
event.stopPropagation()
if (this.pickerContainerTarget.classList.contains("hidden")) {
this.open()
} else {
this.close()
}
}
open() {
if (!this.picker) {
this.createPicker()
}
this.pickerContainerTarget.classList.remove("hidden")
this.setupOutsideClickListener()
}
close() {
this.pickerContainerTarget.classList.add("hidden")
this.removeOutsideClickListener()
}
createPicker() {
this.picker = new Picker({
onEmojiSelect: this.onEmojiSelect.bind(this),
theme: this.getTheme(),
previewPosition: "none",
skinTonePosition: "search",
maxFrequentRows: 2,
perLine: 8,
navPosition: "bottom",
categories: [
"frequent",
"people",
"nature",
"foods",
"activity",
"places",
"objects",
"symbols",
"flags"
]
})
this.pickerContainerTarget.appendChild(this.picker)
}
onEmojiSelect(emoji) {
if (!emoji || !emoji.native) return
// Update input value
this.inputTarget.value = emoji.native
// Update button to show selected emoji
if (this.hasButtonTarget) {
// Find the display element (could be a span or the button itself)
const display = this.buttonTarget.querySelector('[data-emoji-picker-display]') || this.buttonTarget
display.textContent = emoji.native
}
// Close picker
this.close()
// Auto-submit if enabled
if (this.autoSubmitValue) {
this.submitForm()
}
// Dispatch custom event for advanced use cases
this.dispatch("select", { detail: { emoji: emoji.native } })
}
submitForm() {
const form = this.element.closest("form")
if (form && !form.requestSubmit) {
// Fallback for older browsers
form.submit()
} else if (form) {
form.requestSubmit()
}
}
clearEmoji(event) {
event?.preventDefault()
this.inputTarget.value = ""
if (this.hasButtonTarget) {
const display = this.buttonTarget.querySelector('[data-emoji-picker-display]') || this.buttonTarget
// Reset to default emoji or icon
const defaultIcon = this.buttonTarget.dataset.defaultIcon || "😀"
display.textContent = defaultIcon
}
this.dispatch("clear")
}
getTheme() {
// Detect dark mode from document
if (document.documentElement.getAttribute('data-theme') === 'dark' ||
document.documentElement.classList.contains('dark')) {
return 'dark'
}
return 'light'
}
setupKeyboardListeners() {
this.handleKeydown = this.handleKeydown.bind(this)
document.addEventListener("keydown", this.handleKeydown)
}
removeKeyboardListeners() {
document.removeEventListener("keydown", this.handleKeydown)
}
handleKeydown(event) {
// Close on Escape
if (event.key === "Escape" && !this.pickerContainerTarget.classList.contains("hidden")) {
this.close()
}
// Clear on Delete/Backspace (when picker is open)
if ((event.key === "Delete" || event.key === "Backspace") &&
!this.pickerContainerTarget.classList.contains("hidden") &&
event.target === this.inputTarget) {
event.preventDefault()
this.clearEmoji()
}
}
setupOutsideClickListener() {
this.handleOutsideClick = this.handleOutsideClick.bind(this)
// Use setTimeout to avoid immediate triggering from the toggle click
setTimeout(() => {
document.addEventListener("click", this.handleOutsideClick)
}, 0)
}
removeOutsideClickListener() {
if (this.handleOutsideClick) {
document.removeEventListener("click", this.handleOutsideClick)
}
}
handleOutsideClick(event) {
if (!this.element.contains(event.target)) {
this.close()
}
}
removePicker() {
if (this.picker && this.picker.remove) {
this.picker.remove()
}
this.picker = null
}
}

View file

@ -7,7 +7,8 @@ export default class extends Controller {
static values = {
features: Object,
userTheme: String
userTheme: String,
timezone: String
}
connect() {
@ -106,7 +107,8 @@ export default class extends Controller {
});
// Format timestamp for display
const lastSeen = new Date(location.updated_at).toLocaleString();
const timezone = this.timezoneValue || 'UTC';
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
// Create small tooltip that shows automatically
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
@ -176,7 +178,8 @@ export default class extends Controller {
existingMarker.setIcon(newIcon);
// Update tooltip content
const lastSeen = new Date(locationData.updated_at).toLocaleString();
const timezone = this.timezoneValue || 'UTC';
const lastSeen = new Date(locationData.updated_at).toLocaleString('en-US', { timeZone: timezone });
const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery);
existingMarker.setTooltipContent(tooltipContent);
@ -214,7 +217,8 @@ export default class extends Controller {
})
});
const lastSeen = new Date(location.updated_at).toLocaleString();
const timezone = this.timezoneValue || 'UTC';
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone });
const tooltipContent = this.createTooltipContent(lastSeen, location.battery);
familyMarker.bindTooltip(tooltipContent, {

View file

@ -26,16 +26,23 @@ export default class extends BaseController {
received: (data) => {
const row = this.element.querySelector(`tr[data-import-id="${data.import.id}"]`);
if (row) {
const pointsCell = row.querySelector('[data-points-count]');
if (pointsCell) {
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
}
if (!row) return;
const statusCell = row.querySelector('[data-status-display]');
if (statusCell && data.import.status) {
statusCell.textContent = data.import.status;
}
// Handle deletion complete - remove the row
if (data.action === 'delete') {
row.remove();
return;
}
// Handle status and points updates
const pointsCell = row.querySelector('[data-points-count]');
if (pointsCell && data.import.points_count !== undefined) {
pointsCell.textContent = new Intl.NumberFormat().format(data.import.points_count);
}
const statusCell = row.querySelector('[data-status-display]');
if (statusCell && data.import.status) {
statusCell.textContent = data.import.status;
}
}
}

View file

@ -62,7 +62,7 @@ export default class extends Controller {
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const response = await fetch(`/family/update_location_sharing`, {
const response = await fetch(`/family/location_sharing`, {
method: 'PATCH',
headers: {
'Accept': 'application/json',

View file

@ -0,0 +1,68 @@
import { Controller } from '@hotwired/stimulus'
/**
* Map Panel Controller
* Handles tab switching in the map control panel
*/
export default class extends Controller {
static targets = ['tabButton', 'tabContent', 'title']
// Tab title mappings
static titles = {
search: 'Search',
layers: 'Map Layers',
tools: 'Tools',
links: 'Links',
settings: 'Settings'
}
connect() {
console.log('[Map Panel] Connected')
}
/**
* Switch to a different tab
*/
switchTab(event) {
const button = event.currentTarget
const tabName = button.dataset.tab
this.activateTab(tabName)
}
/**
* Programmatically switch to a tab by name
*/
switchToTab(tabName) {
this.activateTab(tabName)
}
/**
* Internal method to activate a tab
*/
activateTab(tabName) {
// Find the button for this tab
const button = this.tabButtonTargets.find(btn => btn.dataset.tab === tabName)
// Update active button
this.tabButtonTargets.forEach(btn => {
btn.classList.remove('active')
})
if (button) {
button.classList.add('active')
}
// Update tab content
this.tabContentTargets.forEach(content => {
const contentTab = content.dataset.tabContent
if (contentTab === tabName) {
content.classList.add('active')
} else {
content.classList.remove('active')
}
})
// Update title
this.titleTarget.textContent = this.constructor.titles[tabName] || tabName
}
}

View file

@ -0,0 +1,540 @@
import { SelectionLayer } from 'maps_maplibre/layers/selection_layer'
import { SelectedPointsLayer } from 'maps_maplibre/layers/selected_points_layer'
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
import { VisitCard } from 'maps_maplibre/components/visit_card'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Manages area selection and bulk operations for Maps V2
* Handles selection mode, visit cards, and bulk actions (merge, confirm, decline)
*/
export class AreaSelectionManager {
constructor(controller) {
this.controller = controller
this.map = controller.map
this.api = controller.api
this.selectionLayer = null
this.selectedPointsLayer = null
this.selectedVisits = []
this.selectedVisitIds = new Set()
}
/**
* Start area selection mode
*/
async startSelectArea() {
console.log('[Maps V2] Starting area selection mode')
// Initialize selection layer if not exists
if (!this.selectionLayer) {
this.selectionLayer = new SelectionLayer(this.map, {
visible: true,
onSelectionComplete: this.handleAreaSelected.bind(this)
})
this.selectionLayer.add({
type: 'FeatureCollection',
features: []
})
console.log('[Maps V2] Selection layer initialized')
}
// Initialize selected points layer if not exists
if (!this.selectedPointsLayer) {
this.selectedPointsLayer = new SelectedPointsLayer(this.map, {
visible: true
})
this.selectedPointsLayer.add({
type: 'FeatureCollection',
features: []
})
console.log('[Maps V2] Selected points layer initialized')
}
// Enable selection mode
this.selectionLayer.enableSelectionMode()
// Update UI - replace Select Area button with Cancel Selection button
if (this.controller.hasSelectAreaButtonTarget) {
this.controller.selectAreaButtonTarget.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
Cancel Selection
`
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#cancelAreaSelection'
}
Toast.info('Draw a rectangle on the map to select points')
}
/**
* Handle area selection completion
*/
async handleAreaSelected(bounds) {
console.log('[Maps V2] Area selected:', bounds)
try {
Toast.info('Fetching data in selected area...')
const [points, visits] = await Promise.all([
this.api.fetchPointsInArea({
start_at: this.controller.startDateValue,
end_at: this.controller.endDateValue,
min_longitude: bounds.minLng,
max_longitude: bounds.maxLng,
min_latitude: bounds.minLat,
max_latitude: bounds.maxLat
}),
this.api.fetchVisitsInArea({
start_at: this.controller.startDateValue,
end_at: this.controller.endDateValue,
sw_lat: bounds.minLat,
sw_lng: bounds.minLng,
ne_lat: bounds.maxLat,
ne_lng: bounds.maxLng
})
])
console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area')
if (points.length === 0 && visits.length === 0) {
Toast.info('No data found in selected area')
this.cancelAreaSelection()
return
}
// Convert points to GeoJSON and display
if (points.length > 0) {
const geojson = pointsToGeoJSON(points)
this.selectedPointsLayer.updateSelectedPoints(geojson)
this.selectedPointsLayer.show()
}
// Display visits in side panel and on map
if (visits.length > 0) {
this.displaySelectedVisits(visits)
}
// Update UI - show action buttons
if (this.controller.hasSelectionActionsTarget) {
this.controller.selectionActionsTarget.classList.remove('hidden')
}
// Update delete button text with count
if (this.controller.hasDeleteButtonTextTarget) {
this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}`
}
// Disable selection mode
this.selectionLayer.disableSelectionMode()
const messages = []
if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`)
if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`)
Toast.success(`Selected ${messages.join(' and ')}`)
} catch (error) {
console.error('[Maps V2] Failed to fetch data in area:', error)
Toast.error('Failed to fetch data in selected area')
this.cancelAreaSelection()
}
}
/**
* Display selected visits in side panel
*/
displaySelectedVisits(visits) {
if (!this.controller.hasSelectedVisitsContainerTarget) return
this.selectedVisits = visits
this.selectedVisitIds = new Set()
const cardsHTML = visits.map(visit =>
VisitCard.create(visit, { isSelected: false })
).join('')
this.controller.selectedVisitsContainerTarget.innerHTML = `
<div class="selected-visits-list">
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-base-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h3 class="text-sm font-bold">Visits in Area (${visits.length})</h3>
</div>
${cardsHTML}
</div>
`
this.controller.selectedVisitsContainerTarget.classList.remove('hidden')
this.attachVisitCardListeners()
requestAnimationFrame(() => {
this.updateBulkActions()
})
}
/**
* Attach event listeners to visit cards
*/
attachVisitCardListeners() {
this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const visitId = parseInt(e.target.dataset.visitSelect)
if (e.target.checked) {
this.selectedVisitIds.add(visitId)
} else {
this.selectedVisitIds.delete(visitId)
}
this.updateBulkActions()
})
})
this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const visitId = parseInt(e.currentTarget.dataset.visitConfirm)
await this.confirmVisit(visitId)
})
})
this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const visitId = parseInt(e.currentTarget.dataset.visitDecline)
await this.declineVisit(visitId)
})
})
}
/**
* Update bulk action buttons visibility and attach listeners
*/
updateBulkActions() {
const selectedCount = this.selectedVisitIds.size
const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline')
existingBulkActions.forEach(el => el.remove())
if (selectedCount >= 2) {
const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card'))
.filter(card => {
const visitId = parseInt(card.dataset.visitId)
return this.selectedVisitIds.has(visitId)
})
if (selectedVisitCards.length > 0) {
const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1]
const bulkActionsDiv = document.createElement('div')
bulkActionsDiv.className = 'bulk-actions-inline mb-2'
bulkActionsDiv.innerHTML = `
<div class="bg-primary/10 border-2 border-primary border-dashed rounded-lg p-3">
<div class="text-xs font-semibold mb-2 text-primary flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected</span>
</div>
<div class="grid grid-cols-3 gap-1.5">
<button class="btn btn-xs btn-outline normal-case" data-bulk-merge>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Merge
</button>
<button class="btn btn-xs btn-primary normal-case" data-bulk-confirm>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Confirm
</button>
<button class="btn btn-xs btn-outline btn-error normal-case" data-bulk-decline>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Decline
</button>
</div>
</div>
`
lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv)
const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]')
const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]')
const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]')
if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits())
if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits())
if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits())
}
}
}
/**
* Confirm a single visit
*/
async confirmVisit(visitId) {
try {
await this.api.updateVisitStatus(visitId, 'confirmed')
Toast.success('Visit confirmed')
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to confirm visit:', error)
Toast.error('Failed to confirm visit')
}
}
/**
* Decline a single visit
*/
async declineVisit(visitId) {
try {
await this.api.updateVisitStatus(visitId, 'declined')
Toast.success('Visit declined')
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to decline visit:', error)
Toast.error('Failed to decline visit')
}
}
/**
* Bulk merge selected visits
*/
async bulkMergeVisits() {
const visitIds = Array.from(this.selectedVisitIds)
if (visitIds.length < 2) {
Toast.error('Select at least 2 visits to merge')
return
}
if (!confirm(`Merge ${visitIds.length} visits into one?`)) {
return
}
try {
Toast.info('Merging visits...')
const mergedVisit = await this.api.mergeVisits(visitIds)
Toast.success('Visits merged successfully')
this.selectedVisitIds.clear()
this.replaceVisitsWithMerged(visitIds, mergedVisit)
this.updateBulkActions()
} catch (error) {
console.error('[Maps V2] Failed to merge visits:', error)
Toast.error('Failed to merge visits')
}
}
/**
* Bulk confirm selected visits
*/
async bulkConfirmVisits() {
const visitIds = Array.from(this.selectedVisitIds)
try {
Toast.info('Confirming visits...')
await this.api.bulkUpdateVisits(visitIds, 'confirmed')
Toast.success(`Confirmed ${visitIds.length} visits`)
this.selectedVisitIds.clear()
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to confirm visits:', error)
Toast.error('Failed to confirm visits')
}
}
/**
* Bulk decline selected visits
*/
async bulkDeclineVisits() {
const visitIds = Array.from(this.selectedVisitIds)
if (!confirm(`Decline ${visitIds.length} visits?`)) {
return
}
try {
Toast.info('Declining visits...')
await this.api.bulkUpdateVisits(visitIds, 'declined')
Toast.success(`Declined ${visitIds.length} visits`)
this.selectedVisitIds.clear()
await this.refreshSelectedVisits()
} catch (error) {
console.error('[Maps V2] Failed to decline visits:', error)
Toast.error('Failed to decline visits')
}
}
/**
* Replace merged visit cards with the new merged visit
*/
replaceVisitsWithMerged(oldVisitIds, mergedVisit) {
const container = this.controller.element.querySelector('.selected-visits-list')
if (!container) return
const mergedStartTime = new Date(mergedVisit.started_at).getTime()
const allCards = Array.from(container.querySelectorAll('.visit-card'))
let insertBeforeCard = null
for (const card of allCards) {
const cardId = parseInt(card.dataset.visitId)
if (oldVisitIds.includes(cardId)) continue
const cardVisit = this.selectedVisits.find(v => v.id === cardId)
if (cardVisit) {
const cardStartTime = new Date(cardVisit.started_at).getTime()
if (cardStartTime > mergedStartTime) {
insertBeforeCard = card
break
}
}
}
oldVisitIds.forEach(id => {
const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`)
if (card) card.remove()
})
this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id))
this.selectedVisits.push(mergedVisit)
this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at))
const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false })
if (insertBeforeCard) {
insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML)
} else {
container.insertAdjacentHTML('beforeend', newCardHTML)
}
const header = container.querySelector('h3')
if (header) {
header.textContent = `Visits in Area (${this.selectedVisits.length})`
}
this.attachVisitCardListeners()
}
/**
* Refresh selected visits after changes
*/
async refreshSelectedVisits() {
const bounds = this.selectionLayer.currentRect
if (!bounds) return
try {
const visits = await this.api.fetchVisitsInArea({
start_at: this.controller.startDateValue,
end_at: this.controller.endDateValue,
sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat,
sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng,
ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat,
ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng
})
this.displaySelectedVisits(visits)
} catch (error) {
console.error('[Maps V2] Failed to refresh visits:', error)
}
}
/**
* Cancel area selection
*/
cancelAreaSelection() {
console.log('[Maps V2] Cancelling area selection')
if (this.selectionLayer) {
this.selectionLayer.disableSelectionMode()
this.selectionLayer.clearSelection()
}
if (this.selectedPointsLayer) {
this.selectedPointsLayer.clearSelection()
}
if (this.controller.hasSelectedVisitsContainerTarget) {
this.controller.selectedVisitsContainerTarget.classList.add('hidden')
this.controller.selectedVisitsContainerTarget.innerHTML = ''
}
if (this.controller.hasSelectedVisitsBulkActionsTarget) {
this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden')
}
this.selectedVisits = []
this.selectedVisitIds = new Set()
if (this.controller.hasSelectAreaButtonTarget) {
this.controller.selectAreaButtonTarget.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<path d="M9 3v18"></path>
<path d="M15 3v18"></path>
<path d="M3 9h18"></path>
<path d="M3 15h18"></path>
</svg>
Select Area
`
this.controller.selectAreaButtonTarget.classList.remove('btn-error')
this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline')
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#startSelectArea'
}
if (this.controller.hasSelectionActionsTarget) {
this.controller.selectionActionsTarget.classList.add('hidden')
}
Toast.info('Selection cancelled')
}
/**
* Delete selected points
*/
async deleteSelectedPoints() {
const pointCount = this.selectedPointsLayer.getCount()
const pointIds = this.selectedPointsLayer.getSelectedPointIds()
if (pointIds.length === 0) {
Toast.error('No points selected')
return
}
const confirmed = confirm(
`Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.`
)
if (!confirmed) return
console.log('[Maps V2] Deleting', pointIds.length, 'points')
try {
Toast.info('Deleting points...')
const result = await this.api.bulkDeletePoints(pointIds)
console.log('[Maps V2] Deleted', result.count, 'points')
this.cancelAreaSelection()
await this.controller.loadMapData({
showLoading: false,
fitBounds: false,
showToast: false
})
Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`)
} catch (error) {
console.error('[Maps V2] Failed to delete points:', error)
Toast.error('Failed to delete points. Please try again.')
}
}
}

View file

@ -0,0 +1,250 @@
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
import { createCircle } from 'maps_maplibre/utils/geometry'
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
/**
* Handles loading and transforming data from API
*/
export class DataLoader {
constructor(api, apiKey, settings = {}) {
this.api = api
this.apiKey = apiKey
this.settings = settings
}
/**
* Update settings (called when user changes settings)
*/
updateSettings(settings) {
this.settings = settings
}
/**
* Fetch all map data (points, visits, photos, areas, tracks)
*/
async fetchMapData(startDate, endDate, onProgress) {
const data = {}
// Fetch points
performanceMonitor.mark('fetch-points')
data.points = await this.api.fetchAllPoints({
start_at: startDate,
end_at: endDate,
onProgress: onProgress
})
performanceMonitor.measure('fetch-points')
// Transform points to GeoJSON
performanceMonitor.mark('transform-geojson')
data.pointsGeoJSON = pointsToGeoJSON(data.points)
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points, {
distanceThresholdMeters: this.settings.metersBetweenRoutes || 1000,
timeThresholdMinutes: this.settings.minutesBetweenRoutes || 60
})
performanceMonitor.measure('transform-geojson')
// Fetch visits
try {
data.visits = await this.api.fetchVisits({
start_at: startDate,
end_at: endDate
})
} catch (error) {
console.warn('Failed to fetch visits:', error)
data.visits = []
}
data.visitsGeoJSON = this.visitsToGeoJSON(data.visits)
// Fetch photos - only if photos layer is enabled and integration is configured
// Skip API call if photos are disabled to avoid blocking on failed integrations
if (this.settings.photosEnabled) {
try {
console.log('[Photos] Fetching photos from:', startDate, 'to', endDate)
// Use Promise.race to enforce a client-side timeout
const photosPromise = this.api.fetchPhotos({
start_at: startDate,
end_at: endDate
})
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Photo fetch timeout')), 15000) // 15 second timeout
)
data.photos = await Promise.race([photosPromise, timeoutPromise])
console.log('[Photos] Fetched photos:', data.photos.length, 'photos')
console.log('[Photos] Sample photo:', data.photos[0])
} catch (error) {
console.warn('[Photos] Failed to fetch photos (non-blocking):', error.message)
data.photos = []
}
} else {
console.log('[Photos] Photos layer disabled, skipping fetch')
data.photos = []
}
data.photosGeoJSON = this.photosToGeoJSON(data.photos)
console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features')
if (data.photosGeoJSON.features.length > 0) {
console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0])
}
// Fetch areas
try {
data.areas = await this.api.fetchAreas()
} catch (error) {
console.warn('Failed to fetch areas:', error)
data.areas = []
}
data.areasGeoJSON = this.areasToGeoJSON(data.areas)
// Fetch places (no date filtering)
try {
data.places = await this.api.fetchPlaces()
} catch (error) {
console.warn('Failed to fetch places:', error)
data.places = []
}
data.placesGeoJSON = this.placesToGeoJSON(data.places)
// Tracks - DISABLED: Backend API not yet implemented
// TODO: Re-enable when /api/v1/tracks endpoint is created
data.tracks = []
data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks)
return data
}
/**
* Convert visits to GeoJSON
*/
visitsToGeoJSON(visits) {
return {
type: 'FeatureCollection',
features: visits.map(visit => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [visit.place.longitude, visit.place.latitude]
},
properties: {
id: visit.id,
name: visit.name,
place_name: visit.place?.name,
status: visit.status,
started_at: visit.started_at,
ended_at: visit.ended_at,
duration: visit.duration
}
}))
}
}
/**
* Convert photos to GeoJSON
*/
photosToGeoJSON(photos) {
return {
type: 'FeatureCollection',
features: photos.map(photo => {
// Construct thumbnail URL
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [photo.longitude, photo.latitude]
},
properties: {
id: photo.id,
thumbnail_url: thumbnailUrl,
taken_at: photo.localDateTime,
filename: photo.originalFileName,
city: photo.city,
state: photo.state,
country: photo.country,
type: photo.type,
source: photo.source
}
}
})
}
}
/**
* Convert places to GeoJSON
*/
placesToGeoJSON(places) {
return {
type: 'FeatureCollection',
features: places.map(place => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [place.longitude, place.latitude]
},
properties: {
id: place.id,
name: place.name,
latitude: place.latitude,
longitude: place.longitude,
note: place.note,
// Stringify tags for MapLibre GL JS compatibility
tags: JSON.stringify(place.tags || []),
// Use first tag's color if available
color: place.tags?.[0]?.color || '#6366f1'
}
}))
}
}
/**
* Convert areas to GeoJSON
* Backend returns circular areas with latitude, longitude, radius
*/
areasToGeoJSON(areas) {
return {
type: 'FeatureCollection',
features: areas.map(area => {
// Create circle polygon from center and radius
// Parse as floats since API returns strings
const center = [parseFloat(area.longitude), parseFloat(area.latitude)]
const coordinates = createCircle(center, area.radius)
return {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [coordinates]
},
properties: {
id: area.id,
name: area.name,
color: area.color || '#ef4444',
radius: area.radius
}
}
})
}
}
/**
* Convert tracks to GeoJSON
*/
tracksToGeoJSON(tracks) {
return {
type: 'FeatureCollection',
features: tracks.map(track => ({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: track.coordinates
},
properties: {
id: track.id,
name: track.name,
color: track.color || '#8b5cf6'
}
}))
}
}
}

View file

@ -0,0 +1,35 @@
/**
* Manages date formatting and range calculations
*/
export class DateManager {
/**
* Format date for API requests (matching V1 format)
* Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59")
*/
static formatDateForAPI(date) {
const pad = (n) => String(n).padStart(2, '0')
const year = date.getFullYear()
const month = pad(date.getMonth() + 1)
const day = pad(date.getDate())
const hours = pad(date.getHours())
const minutes = pad(date.getMinutes())
return `${year}-${month}-${day}T${hours}:${minutes}`
}
/**
* Parse month selector value to date range
*/
static parseMonthSelector(value) {
const [year, month] = value.split('-')
const startDate = new Date(year, month - 1, 1, 0, 0, 0)
const lastDay = new Date(year, month, 0).getDate()
const endDate = new Date(year, month - 1, lastDay, 23, 59, 0)
return {
startDate: this.formatDateForAPI(startDate),
endDate: this.formatDateForAPI(endDate)
}
}
}

View file

@ -0,0 +1,129 @@
import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers'
/**
* Handles map interaction events (clicks, info display)
*/
export class EventHandlers {
constructor(map, controller) {
this.map = map
this.controller = controller
}
/**
* Handle point click
*/
handlePointClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
<div><span class="font-semibold">Time:</span> ${formatTimestamp(properties.timestamp, this.controller.timezoneValue)}</div>
${properties.battery ? `<div><span class="font-semibold">Battery:</span> ${properties.battery}%</div>` : ''}
${properties.altitude ? `<div><span class="font-semibold">Altitude:</span> ${Math.round(properties.altitude)}m</div>` : ''}
${properties.velocity ? `<div><span class="font-semibold">Speed:</span> ${Math.round(properties.velocity)} km/h</div>` : ''}
</div>
`
this.controller.showInfo('Location Point', content)
}
/**
* Handle visit click
*/
handleVisitClick(e) {
const feature = e.features[0]
const properties = feature.properties
const startTime = formatTimestamp(properties.started_at, this.controller.timezoneValue)
const endTime = formatTimestamp(properties.ended_at, this.controller.timezoneValue)
const durationHours = Math.round(properties.duration / 3600)
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m`
const content = `
<div class="space-y-2">
<div class="badge badge-sm ${properties.status === 'confirmed' ? 'badge-success' : 'badge-warning'}">${properties.status}</div>
<div><span class="font-semibold">Arrived:</span> ${startTime}</div>
<div><span class="font-semibold">Left:</span> ${endTime}</div>
<div><span class="font-semibold">Duration:</span> ${durationDisplay}</div>
</div>
`
const actions = [{
type: 'button',
handler: 'handleEdit',
id: properties.id,
entityType: 'visit',
label: 'Edit'
}]
this.controller.showInfo(properties.name || properties.place_name || 'Visit', content, actions)
}
/**
* Handle photo click
*/
handlePhotoClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
${properties.photo_url ? `<img src="${properties.photo_url}" alt="Photo" class="w-full rounded-lg mb-2" />` : ''}
${properties.taken_at ? `<div><span class="font-semibold">Taken:</span> ${formatTimestamp(properties.taken_at, this.controller.timezoneValue)}</div>` : ''}
</div>
`
this.controller.showInfo('Photo', content)
}
/**
* Handle place click
*/
handlePlaceClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
${properties.tag ? `<div class="badge badge-sm badge-primary">${properties.tag}</div>` : ''}
${properties.description ? `<div>${properties.description}</div>` : ''}
</div>
`
const actions = properties.id ? [{
type: 'button',
handler: 'handleEdit',
id: properties.id,
entityType: 'place',
label: 'Edit'
}] : []
this.controller.showInfo(properties.name || 'Place', content, actions)
}
/**
* Handle area click
*/
handleAreaClick(e) {
const feature = e.features[0]
const properties = feature.properties
const content = `
<div class="space-y-2">
${properties.radius ? `<div><span class="font-semibold">Radius:</span> ${Math.round(properties.radius)}m</div>` : ''}
${properties.latitude && properties.longitude ? `<div><span class="font-semibold">Center:</span> ${properties.latitude.toFixed(6)}, ${properties.longitude.toFixed(6)}</div>` : ''}
</div>
`
const actions = properties.id ? [{
type: 'button',
handler: 'handleDelete',
id: properties.id,
entityType: 'area',
label: 'Delete'
}] : []
this.controller.showInfo(properties.name || 'Area', content, actions)
}
}

View file

@ -0,0 +1,53 @@
/**
* Manages filtering and searching of map data
*/
export class FilterManager {
constructor(dataLoader) {
this.dataLoader = dataLoader
this.currentVisitFilter = 'all'
this.allVisits = []
}
/**
* Store all visits for filtering
*/
setAllVisits(visits) {
this.allVisits = visits
}
/**
* Filter and update visits display
*/
filterAndUpdateVisits(searchTerm, statusFilter, visitsLayer) {
if (!this.allVisits || !visitsLayer) return
const filtered = this.allVisits.filter(visit => {
// Apply search
const matchesSearch = !searchTerm ||
visit.name?.toLowerCase().includes(searchTerm) ||
visit.place?.name?.toLowerCase().includes(searchTerm)
// Apply status filter
const matchesStatus = statusFilter === 'all' || visit.status === statusFilter
return matchesSearch && matchesStatus
})
const geojson = this.dataLoader.visitsToGeoJSON(filtered)
visitsLayer.update(geojson)
}
/**
* Get current visit filter
*/
getCurrentVisitFilter() {
return this.currentVisitFilter
}
/**
* Set current visit filter
*/
setCurrentVisitFilter(filter) {
this.currentVisitFilter = filter
}
}

View file

@ -0,0 +1,281 @@
import { PointsLayer } from 'maps_maplibre/layers/points_layer'
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
import { HeatmapLayer } from 'maps_maplibre/layers/heatmap_layer'
import { VisitsLayer } from 'maps_maplibre/layers/visits_layer'
import { PhotosLayer } from 'maps_maplibre/layers/photos_layer'
import { AreasLayer } from 'maps_maplibre/layers/areas_layer'
import { TracksLayer } from 'maps_maplibre/layers/tracks_layer'
import { PlacesLayer } from 'maps_maplibre/layers/places_layer'
import { FogLayer } from 'maps_maplibre/layers/fog_layer'
import { FamilyLayer } from 'maps_maplibre/layers/family_layer'
import { RecentPointLayer } from 'maps_maplibre/layers/recent_point_layer'
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
/**
* Manages all map layers lifecycle and visibility
*/
export class LayerManager {
constructor(map, settings, api) {
this.map = map
this.settings = settings
this.api = api
this.layers = {}
}
/**
* Add or update all layers with provided data
*/
async addAllLayers(pointsGeoJSON, routesGeoJSON, visitsGeoJSON, photosGeoJSON, areasGeoJSON, tracksGeoJSON, placesGeoJSON) {
performanceMonitor.mark('add-layers')
// Layer order matters - layers added first render below layers added later
// Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> places -> photos -> family -> points -> recent-point (top) -> fog (canvas overlay)
await this._addScratchLayer(pointsGeoJSON)
this._addHeatmapLayer(pointsGeoJSON)
this._addAreasLayer(areasGeoJSON)
this._addTracksLayer(tracksGeoJSON)
this._addRoutesLayer(routesGeoJSON)
this._addVisitsLayer(visitsGeoJSON)
this._addPlacesLayer(placesGeoJSON)
// Add photos layer with error handling (async, might fail loading images)
try {
await this._addPhotosLayer(photosGeoJSON)
} catch (error) {
console.warn('Failed to add photos layer:', error)
}
this._addFamilyLayer()
this._addPointsLayer(pointsGeoJSON)
this._addRecentPointLayer()
this._addFogLayer(pointsGeoJSON)
performanceMonitor.measure('add-layers')
}
/**
* Setup event handlers for layer interactions
*/
setupLayerEventHandlers(handlers) {
// Click handlers
this.map.on('click', 'points', handlers.handlePointClick)
this.map.on('click', 'visits', handlers.handleVisitClick)
this.map.on('click', 'photos', handlers.handlePhotoClick)
this.map.on('click', 'places', handlers.handlePlaceClick)
// Areas have multiple layers (fill, outline, labels)
this.map.on('click', 'areas-fill', handlers.handleAreaClick)
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
// Cursor change on hover
this.map.on('mouseenter', 'points', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'points', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'visits', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'visits', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'photos', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'photos', () => {
this.map.getCanvas().style.cursor = ''
})
this.map.on('mouseenter', 'places', () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', 'places', () => {
this.map.getCanvas().style.cursor = ''
})
// Areas hover handlers for all sub-layers
const areaLayers = ['areas-fill', 'areas-outline', 'areas-labels']
areaLayers.forEach(layerId => {
// Only add handlers if layer exists
if (this.map.getLayer(layerId)) {
this.map.on('mouseenter', layerId, () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', layerId, () => {
this.map.getCanvas().style.cursor = ''
})
}
})
}
/**
* Toggle layer visibility
*/
toggleLayer(layerName) {
const layer = this.layers[`${layerName}Layer`]
if (!layer) return null
layer.toggle()
return layer.visible
}
/**
* Get layer instance
*/
getLayer(layerName) {
return this.layers[`${layerName}Layer`]
}
/**
* Clear all layer references (for style changes)
*/
clearLayerReferences() {
this.layers = {}
}
// Private methods for individual layer management
async _addScratchLayer(pointsGeoJSON) {
try {
if (!this.layers.scratchLayer && this.settings.scratchEnabled) {
const ScratchLayer = await lazyLoader.loadLayer('scratch')
this.layers.scratchLayer = new ScratchLayer(this.map, {
visible: true,
apiClient: this.api
})
await this.layers.scratchLayer.add(pointsGeoJSON)
} else if (this.layers.scratchLayer) {
await this.layers.scratchLayer.update(pointsGeoJSON)
}
} catch (error) {
console.warn('Failed to load scratch layer:', error)
}
}
_addHeatmapLayer(pointsGeoJSON) {
if (!this.layers.heatmapLayer) {
this.layers.heatmapLayer = new HeatmapLayer(this.map, {
visible: this.settings.heatmapEnabled
})
this.layers.heatmapLayer.add(pointsGeoJSON)
} else {
this.layers.heatmapLayer.update(pointsGeoJSON)
}
}
_addAreasLayer(areasGeoJSON) {
if (!this.layers.areasLayer) {
this.layers.areasLayer = new AreasLayer(this.map, {
visible: this.settings.areasEnabled || false
})
this.layers.areasLayer.add(areasGeoJSON)
} else {
this.layers.areasLayer.update(areasGeoJSON)
}
}
_addTracksLayer(tracksGeoJSON) {
if (!this.layers.tracksLayer) {
this.layers.tracksLayer = new TracksLayer(this.map, {
visible: this.settings.tracksEnabled || false
})
this.layers.tracksLayer.add(tracksGeoJSON)
} else {
this.layers.tracksLayer.update(tracksGeoJSON)
}
}
_addRoutesLayer(routesGeoJSON) {
if (!this.layers.routesLayer) {
this.layers.routesLayer = new RoutesLayer(this.map, {
visible: this.settings.routesVisible !== false // Default true unless explicitly false
})
this.layers.routesLayer.add(routesGeoJSON)
} else {
this.layers.routesLayer.update(routesGeoJSON)
}
}
_addVisitsLayer(visitsGeoJSON) {
if (!this.layers.visitsLayer) {
this.layers.visitsLayer = new VisitsLayer(this.map, {
visible: this.settings.visitsEnabled || false
})
this.layers.visitsLayer.add(visitsGeoJSON)
} else {
this.layers.visitsLayer.update(visitsGeoJSON)
}
}
_addPlacesLayer(placesGeoJSON) {
if (!this.layers.placesLayer) {
this.layers.placesLayer = new PlacesLayer(this.map, {
visible: this.settings.placesEnabled || false
})
this.layers.placesLayer.add(placesGeoJSON)
} else {
this.layers.placesLayer.update(placesGeoJSON)
}
}
async _addPhotosLayer(photosGeoJSON) {
console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled)
if (!this.layers.photosLayer) {
this.layers.photosLayer = new PhotosLayer(this.map, {
visible: this.settings.photosEnabled || false
})
console.log('[Photos] Created new PhotosLayer instance')
await this.layers.photosLayer.add(photosGeoJSON)
console.log('[Photos] Added photos to layer')
} else {
console.log('[Photos] Updating existing PhotosLayer')
await this.layers.photosLayer.update(photosGeoJSON)
console.log('[Photos] Updated photos layer')
}
}
_addFamilyLayer() {
if (!this.layers.familyLayer) {
this.layers.familyLayer = new FamilyLayer(this.map, {
visible: false // Initially hidden, shown when family locations arrive via ActionCable
})
this.layers.familyLayer.add({ type: 'FeatureCollection', features: [] })
}
}
_addPointsLayer(pointsGeoJSON) {
if (!this.layers.pointsLayer) {
this.layers.pointsLayer = new PointsLayer(this.map, {
visible: this.settings.pointsVisible !== false, // Default true unless explicitly false
apiClient: this.api,
layerManager: this
})
this.layers.pointsLayer.add(pointsGeoJSON)
} else {
this.layers.pointsLayer.update(pointsGeoJSON)
}
}
_addRecentPointLayer() {
if (!this.layers.recentPointLayer) {
this.layers.recentPointLayer = new RecentPointLayer(this.map, {
visible: false // Initially hidden, shown only when live mode is enabled
})
this.layers.recentPointLayer.add({ type: 'FeatureCollection', features: [] })
}
}
_addFogLayer(pointsGeoJSON) {
// Always create fog layer for backward compatibility
if (!this.layers.fogLayer) {
this.layers.fogLayer = new FogLayer(this.map, {
clearRadius: this.settings.fogOfWarRadius || 1000,
visible: this.settings.fogEnabled || false
})
this.layers.fogLayer.add(pointsGeoJSON)
} else {
this.layers.fogLayer.update(pointsGeoJSON)
}
}
}

View file

@ -0,0 +1,131 @@
import maplibregl from 'maplibre-gl'
import { Toast } from 'maps_maplibre/components/toast'
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
/**
* Manages data loading and layer setup for the map
*/
export class MapDataManager {
constructor(controller) {
this.controller = controller
this.map = controller.map
this.dataLoader = controller.dataLoader
this.layerManager = controller.layerManager
this.filterManager = controller.filterManager
this.eventHandlers = controller.eventHandlers
}
/**
* Load map data from API and setup layers
* @param {string} startDate - Start date for data range
* @param {string} endDate - End date for data range
* @param {Object} options - Loading options
*/
async loadMapData(startDate, endDate, options = {}) {
const {
showLoading = true,
fitBounds = true,
showToast = true,
onProgress = null
} = options
performanceMonitor.mark('load-map-data')
if (showLoading) {
this.controller.showLoading()
}
try {
// Fetch data from API
const data = await this.dataLoader.fetchMapData(
startDate,
endDate,
showLoading ? onProgress : null
)
// Store visits for filtering
this.filterManager.setAllVisits(data.visits)
// Setup layers
await this._setupLayers(data)
// Fit bounds if requested
if (fitBounds && data.points.length > 0) {
this._fitMapToBounds(data.pointsGeoJSON)
}
// Show success message
if (showToast) {
const pointText = data.points.length === 1 ? 'point' : 'points'
Toast.success(`Loaded ${data.points.length} location ${pointText}`)
}
return data
} catch (error) {
console.error('[MapDataManager] Failed to load map data:', error)
Toast.error('Failed to load location data. Please try again.')
throw error
} finally {
if (showLoading) {
this.controller.hideLoading()
}
const duration = performanceMonitor.measure('load-map-data')
console.log(`[Performance] Map data loaded in ${duration}ms`)
}
}
/**
* Setup all map layers with loaded data
* @private
*/
async _setupLayers(data) {
const addAllLayers = async () => {
await this.layerManager.addAllLayers(
data.pointsGeoJSON,
data.routesGeoJSON,
data.visitsGeoJSON,
data.photosGeoJSON,
data.areasGeoJSON,
data.tracksGeoJSON,
data.placesGeoJSON
)
this.layerManager.setupLayerEventHandlers({
handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers),
handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers),
handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers),
handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers),
handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers)
})
}
if (this.map.loaded()) {
await addAllLayers()
} else {
this.map.once('load', async () => {
await addAllLayers()
})
}
}
/**
* Fit map to data bounds
* @private
*/
_fitMapToBounds(geojson) {
if (!geojson?.features?.length) {
return
}
const coordinates = geojson.features.map(f => f.geometry.coordinates)
const bounds = coordinates.reduce((bounds, coord) => {
return bounds.extend(coord)
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
this.map.fitBounds(bounds, {
padding: 50,
maxZoom: 15
})
}
}

View file

@ -0,0 +1,84 @@
import maplibregl from 'maplibre-gl'
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
/**
* Handles map initialization for Maps V2
*/
export class MapInitializer {
/**
* Initialize MapLibre map instance
* @param {HTMLElement} container - The container element for the map
* @param {Object} settings - Map settings (style, center, zoom)
* @returns {Promise<maplibregl.Map>} The initialized map instance
*/
static async initialize(container, settings = {}) {
const {
mapStyle = 'streets',
center = [0, 0],
zoom = 2,
showControls = true,
globeProjection = false
} = settings
const style = await getMapStyle(mapStyle)
const mapOptions = {
container,
style,
center,
zoom
}
const map = new maplibregl.Map(mapOptions)
// Set globe projection after map loads
if (globeProjection === true || globeProjection === 'true') {
map.on('load', () => {
map.setProjection({ type: 'globe' })
// Add atmosphere effect
map.setSky({
'atmosphere-blend': [
'interpolate', ['linear'], ['zoom'],
0, 1, 5, 1, 7, 0
]
})
})
}
if (showControls) {
map.addControl(new maplibregl.NavigationControl(), 'top-right')
}
return map
}
/**
* Fit map to bounds of GeoJSON features
* @param {maplibregl.Map} map - The map instance
* @param {Object} geojson - GeoJSON FeatureCollection
* @param {Object} options - Fit bounds options
*/
static fitToBounds(map, geojson, options = {}) {
const {
padding = 50,
maxZoom = 15
} = options
if (!geojson?.features?.length) {
console.warn('[MapInitializer] No features to fit bounds to')
return
}
const coordinates = geojson.features.map(f => f.geometry.coordinates)
const bounds = coordinates.reduce((bounds, coord) => {
return bounds.extend(coord)
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
map.fitBounds(bounds, {
padding,
maxZoom
})
}
}

View file

@ -0,0 +1,281 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Manages places-related operations for Maps V2
* Including place creation, tag filtering, and layer management
*/
export class PlacesManager {
constructor(controller) {
this.controller = controller
this.layerManager = controller.layerManager
this.api = controller.api
this.dataLoader = controller.dataLoader
this.settings = controller.settings
}
/**
* Toggle places layer
*/
togglePlaces(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('placesEnabled', enabled)
const placesLayer = this.layerManager.getLayer('places')
if (placesLayer) {
if (enabled) {
placesLayer.show()
if (this.controller.hasPlacesFiltersTarget) {
this.controller.placesFiltersTarget.style.display = 'block'
}
this.initializePlaceTagFilters()
} else {
placesLayer.hide()
if (this.controller.hasPlacesFiltersTarget) {
this.controller.placesFiltersTarget.style.display = 'none'
}
}
}
}
/**
* Initialize place tag filters (enable all by default or restore saved state)
*/
initializePlaceTagFilters() {
const savedFilters = this.settings.placesTagFilters
if (savedFilters && savedFilters.length > 0) {
this.restoreSavedTagFilters(savedFilters)
} else {
this.enableAllTagsInitial()
}
}
/**
* Restore saved tag filters
*/
restoreSavedTagFilters(savedFilters) {
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
tagCheckboxes.forEach(checkbox => {
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
const shouldBeChecked = savedFilters.includes(value)
if (checkbox.checked !== shouldBeChecked) {
checkbox.checked = shouldBeChecked
const badge = checkbox.nextElementSibling
const color = badge.style.borderColor
if (shouldBeChecked) {
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
} else {
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.color = color
}
}
})
this.syncEnableAllTagsToggle()
this.loadPlacesWithTags(savedFilters)
}
/**
* Enable all tags initially
*/
enableAllTagsInitial() {
if (this.controller.hasEnableAllPlaceTagsToggleTarget) {
this.controller.enableAllPlaceTagsToggleTarget.checked = true
}
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
const allTagIds = []
tagCheckboxes.forEach(checkbox => {
checkbox.checked = true
const badge = checkbox.nextElementSibling
const color = badge.style.borderColor
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
allTagIds.push(value)
})
SettingsManager.updateSetting('placesTagFilters', allTagIds)
this.loadPlacesWithTags(allTagIds)
}
/**
* Get selected place tag IDs
*/
getSelectedPlaceTags() {
return Array.from(
document.querySelectorAll('input[name="place_tag_ids[]"]:checked')
).map(cb => {
const value = cb.value
return value === 'untagged' ? value : parseInt(value)
})
}
/**
* Filter places by selected tags
*/
filterPlacesByTags(event) {
const badge = event.target.nextElementSibling
const color = badge.style.borderColor
if (event.target.checked) {
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
} else {
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.color = color
}
this.syncEnableAllTagsToggle()
const checkedTags = this.getSelectedPlaceTags()
SettingsManager.updateSetting('placesTagFilters', checkedTags)
this.loadPlacesWithTags(checkedTags)
}
/**
* Sync "Enable All Tags" toggle with individual tag states
*/
syncEnableAllTagsToggle() {
if (!this.controller.hasEnableAllPlaceTagsToggleTarget) return
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
const allChecked = Array.from(tagCheckboxes).every(cb => cb.checked)
this.controller.enableAllPlaceTagsToggleTarget.checked = allChecked
}
/**
* Load places filtered by tags
*/
async loadPlacesWithTags(tagIds = []) {
try {
let places = []
if (tagIds.length > 0) {
places = await this.api.fetchPlaces({ tag_ids: tagIds })
}
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
const placesLayer = this.layerManager.getLayer('places')
if (placesLayer) {
placesLayer.update(placesGeoJSON)
}
} catch (error) {
console.error('[Maps V2] Failed to load places:', error)
}
}
/**
* Toggle all place tags on/off
*/
toggleAllPlaceTags(event) {
const enableAll = event.target.checked
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
tagCheckboxes.forEach(checkbox => {
if (checkbox.checked !== enableAll) {
checkbox.checked = enableAll
const badge = checkbox.nextElementSibling
const color = badge.style.borderColor
if (enableAll) {
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.color = 'white'
} else {
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.color = color
}
}
})
const selectedTags = this.getSelectedPlaceTags()
SettingsManager.updateSetting('placesTagFilters', selectedTags)
this.loadPlacesWithTags(selectedTags)
}
/**
* Start create place mode
*/
startCreatePlace() {
console.log('[Maps V2] Starting create place mode')
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
this.controller.toggleSettings()
}
this.controller.map.getCanvas().style.cursor = 'crosshair'
Toast.info('Click on the map to place a place')
this.handleCreatePlaceClick = (e) => {
const { lng, lat } = e.lngLat
document.dispatchEvent(new CustomEvent('place:create', {
detail: { latitude: lat, longitude: lng }
}))
this.controller.map.getCanvas().style.cursor = ''
}
this.controller.map.once('click', this.handleCreatePlaceClick)
}
/**
* Handle place creation event - reload places and update layer
*/
async handlePlaceCreated(event) {
console.log('[Maps V2] Place created, reloading places...', event.detail)
try {
const selectedTags = this.getSelectedPlaceTags()
const places = await this.api.fetchPlaces({
tag_ids: selectedTags
})
console.log('[Maps V2] Fetched places:', places.length)
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features')
const placesLayer = this.layerManager.getLayer('places')
if (placesLayer) {
placesLayer.update(placesGeoJSON)
console.log('[Maps V2] Places layer updated successfully')
} else {
console.warn('[Maps V2] Places layer not found, cannot update')
}
} catch (error) {
console.error('[Maps V2] Failed to reload places:', error)
}
}
/**
* Handle place update event - reload places and update layer
*/
async handlePlaceUpdated(event) {
console.log('[Maps V2] Place updated, reloading places...', event.detail)
// Reuse the same logic as creation
await this.handlePlaceCreated(event)
}
}

View file

@ -0,0 +1,384 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { Toast } from 'maps_maplibre/components/toast'
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
/**
* Manages routes-related operations for Maps V2
* Including speed-colored routes, route generation, and layer management
*/
export class RoutesManager {
constructor(controller) {
this.controller = controller
this.map = controller.map
this.layerManager = controller.layerManager
this.settings = controller.settings
}
/**
* Toggle routes layer visibility
*/
toggleRoutes(event) {
const element = event.currentTarget
const visible = element.checked
const routesLayer = this.layerManager.getLayer('routes')
if (routesLayer) {
routesLayer.toggle(visible)
}
if (this.controller.hasRoutesOptionsTarget) {
this.controller.routesOptionsTarget.style.display = visible ? 'block' : 'none'
}
SettingsManager.updateSetting('routesVisible', visible)
}
/**
* Toggle speed-colored routes
*/
async toggleSpeedColoredRoutes(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled)
if (this.controller.hasSpeedColorScaleContainerTarget) {
this.controller.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled)
}
await this.reloadRoutes()
}
/**
* Open speed color editor modal
*/
openSpeedColorEditor() {
const currentScale = this.controller.speedColorScaleInputTarget.value ||
'0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
let modal = document.getElementById('speed-color-editor-modal')
if (!modal) {
modal = this.createSpeedColorEditorModal(currentScale)
document.body.appendChild(modal)
} else {
const controller = this.controller.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor')
if (controller) {
controller.colorStopsValue = currentScale
controller.loadColorStops()
}
}
const checkbox = modal.querySelector('.modal-toggle')
if (checkbox) {
checkbox.checked = true
}
}
/**
* Create speed color editor modal element
*/
createSpeedColorEditorModal(currentScale) {
const modal = document.createElement('div')
modal.id = 'speed-color-editor-modal'
modal.setAttribute('data-controller', 'speed-color-editor')
modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale)
modal.setAttribute('data-action', 'speed-color-editor:save->maps--maplibre#handleSpeedColorSave')
modal.innerHTML = `
<input type="checkbox" id="speed-color-editor-toggle" class="modal-toggle" />
<div class="modal" role="dialog" data-speed-color-editor-target="modal">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold mb-4">Edit Speed Color Gradient</h3>
<div class="space-y-4">
<!-- Gradient Preview -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Preview</span>
</label>
<div class="h-12 rounded-lg border-2 border-base-300"
data-speed-color-editor-target="preview"></div>
<label class="label">
<span class="label-text-alt">This gradient will be applied to routes based on speed</span>
</label>
</div>
<!-- Color Stops List -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Color Stops</span>
</label>
<div class="space-y-2" data-speed-color-editor-target="stopsList"></div>
</div>
<!-- Add Stop Button -->
<button type="button"
class="btn btn-sm btn-outline w-full"
data-action="click->speed-color-editor#addStop">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Color Stop
</button>
</div>
<div class="modal-action">
<button type="button"
class="btn btn-ghost"
data-action="click->speed-color-editor#resetToDefault">
Reset to Default
</button>
<button type="button"
class="btn"
data-action="click->speed-color-editor#close">
Cancel
</button>
<button type="button"
class="btn btn-primary"
data-action="click->speed-color-editor#save">
Save
</button>
</div>
</div>
<label class="modal-backdrop" for="speed-color-editor-toggle"></label>
</div>
`
return modal
}
/**
* Handle speed color save event from editor
*/
handleSpeedColorSave(event) {
const newScale = event.detail.colorStops
this.controller.speedColorScaleInputTarget.value = newScale
SettingsManager.updateSetting('speedColorScale', newScale)
if (this.controller.speedColoredToggleTarget.checked) {
this.reloadRoutes()
}
}
/**
* Reload routes layer
*/
async reloadRoutes() {
this.controller.showLoading('Reloading routes...')
try {
const pointsLayer = this.layerManager.getLayer('points')
const points = pointsLayer?.data?.features?.map(f => ({
latitude: f.geometry.coordinates[1],
longitude: f.geometry.coordinates[0],
timestamp: f.properties.timestamp
})) || []
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 1000
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors')
const routesGeoJSON = await this.generateRoutesWithSpeedColors(
points,
{ distanceThresholdMeters, timeThresholdMinutes },
calculateSpeed,
getSpeedColor
)
this.layerManager.updateLayer('routes', routesGeoJSON)
} catch (error) {
console.error('Failed to reload routes:', error)
Toast.error('Failed to reload routes')
} finally {
this.controller.hideLoading()
}
}
/**
* Generate routes with speed coloring
*/
async generateRoutesWithSpeedColors(points, options, calculateSpeed, getSpeedColor) {
const { RoutesLayer } = await import('maps_maplibre/layers/routes_layer')
const useSpeedColors = this.settings.speedColoredRoutesEnabled || false
const speedColorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
const routesGeoJSON = RoutesLayer.pointsToRoutes(points, options)
if (!useSpeedColors) {
return routesGeoJSON
}
routesGeoJSON.features = routesGeoJSON.features.map((feature, index) => {
const segment = points.slice(
points.findIndex(p => p.timestamp === feature.properties.startTime),
points.findIndex(p => p.timestamp === feature.properties.endTime) + 1
)
if (segment.length >= 2) {
const speed = calculateSpeed(segment[0], segment[segment.length - 1])
const color = getSpeedColor(speed, useSpeedColors, speedColorScale)
feature.properties.speed = speed
feature.properties.color = color
}
return feature
})
return routesGeoJSON
}
/**
* Toggle heatmap visibility
*/
toggleHeatmap(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('heatmapEnabled', enabled)
const heatmapLayer = this.layerManager.getLayer('heatmap')
if (heatmapLayer) {
if (enabled) {
heatmapLayer.show()
} else {
heatmapLayer.hide()
}
}
}
/**
* Toggle fog of war layer
*/
toggleFog(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('fogEnabled', enabled)
const fogLayer = this.layerManager.getLayer('fog')
if (fogLayer) {
fogLayer.toggle(enabled)
} else {
console.warn('Fog layer not yet initialized')
}
}
/**
* Toggle scratch map layer
*/
async toggleScratch(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('scratchEnabled', enabled)
try {
const scratchLayer = this.layerManager.getLayer('scratch')
if (!scratchLayer && enabled) {
const ScratchLayer = await lazyLoader.loadLayer('scratch')
const newScratchLayer = new ScratchLayer(this.map, {
visible: true,
apiClient: this.controller.api
})
const pointsLayer = this.layerManager.getLayer('points')
const pointsData = pointsLayer?.data || { type: 'FeatureCollection', features: [] }
await newScratchLayer.add(pointsData)
this.layerManager.layers.scratchLayer = newScratchLayer
} else if (scratchLayer) {
if (enabled) {
scratchLayer.show()
} else {
scratchLayer.hide()
}
}
} catch (error) {
console.error('Failed to toggle scratch layer:', error)
Toast.error('Failed to load scratch layer')
}
}
/**
* Toggle photos layer
*/
togglePhotos(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('photosEnabled', enabled)
const photosLayer = this.layerManager.getLayer('photos')
if (photosLayer) {
if (enabled) {
photosLayer.show()
} else {
photosLayer.hide()
}
}
}
/**
* Toggle areas layer
*/
toggleAreas(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('areasEnabled', enabled)
const areasLayer = this.layerManager.getLayer('areas')
if (areasLayer) {
if (enabled) {
areasLayer.show()
} else {
areasLayer.hide()
}
}
}
/**
* Toggle tracks layer
*/
toggleTracks(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('tracksEnabled', enabled)
const tracksLayer = this.layerManager.getLayer('tracks')
if (tracksLayer) {
if (enabled) {
tracksLayer.show()
} else {
tracksLayer.hide()
}
}
}
/**
* Toggle points layer visibility
*/
togglePoints(event) {
const element = event.currentTarget
const visible = element.checked
const pointsLayer = this.layerManager.getLayer('points')
if (pointsLayer) {
pointsLayer.toggle(visible)
}
SettingsManager.updateSetting('pointsVisible', visible)
}
/**
* Toggle family members layer
*/
async toggleFamily(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('familyEnabled', enabled)
const familyLayer = this.layerManager.getLayer('family')
if (familyLayer) {
if (enabled) {
familyLayer.show()
// Load family members data
await this.controller.loadFamilyMembers()
} else {
familyLayer.hide()
}
}
// Show/hide the family members list
if (this.controller.hasFamilyMembersListTarget) {
this.controller.familyMembersListTarget.style.display = enabled ? 'block' : 'none'
}
}
}

View file

@ -0,0 +1,307 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Handles all settings-related operations for Maps V2
* Including toggles, advanced settings, and UI synchronization
*/
export class SettingsController {
constructor(controller) {
this.controller = controller
this.settings = controller.settings
}
// Lazy getters for properties that may not be initialized yet
get map() {
return this.controller.map
}
get layerManager() {
return this.controller.layerManager
}
/**
* Load settings (sync from backend)
*/
async loadSettings() {
this.settings = await SettingsManager.sync()
this.controller.settings = this.settings
// Update dataLoader with new settings
if (this.controller.dataLoader) {
this.controller.dataLoader.updateSettings(this.settings)
}
return this.settings
}
/**
* Sync UI controls with loaded settings
*/
syncToggleStates() {
const controller = this.controller
// Sync layer toggles
const toggleMap = {
pointsToggle: 'pointsVisible',
routesToggle: 'routesVisible',
heatmapToggle: 'heatmapEnabled',
visitsToggle: 'visitsEnabled',
photosToggle: 'photosEnabled',
areasToggle: 'areasEnabled',
placesToggle: 'placesEnabled',
fogToggle: 'fogEnabled',
scratchToggle: 'scratchEnabled',
familyToggle: 'familyEnabled',
speedColoredToggle: 'speedColoredRoutesEnabled'
}
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
const target = `${targetName}Target`
const hasTarget = `has${targetName.charAt(0).toUpperCase()}${targetName.slice(1)}Target`
if (controller[hasTarget]) {
controller[target].checked = this.settings[settingKey]
}
})
// Show/hide visits search based on initial toggle state
if (controller.hasVisitsToggleTarget && controller.hasVisitsSearchTarget) {
controller.visitsSearchTarget.style.display = controller.visitsToggleTarget.checked ? 'block' : 'none'
}
// Show/hide places filters based on initial toggle state
if (controller.hasPlacesToggleTarget && controller.hasPlacesFiltersTarget) {
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
}
// Show/hide family members list based on initial toggle state
if (controller.hasFamilyToggleTarget && controller.hasFamilyMembersListTarget && controller.familyToggleTarget) {
controller.familyMembersListTarget.style.display = controller.familyToggleTarget.checked ? 'block' : 'none'
}
// Sync route opacity slider
if (controller.hasRouteOpacityRangeTarget) {
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
}
// Sync map style dropdown
const mapStyleSelect = controller.element.querySelector('select[name="mapStyle"]')
if (mapStyleSelect) {
mapStyleSelect.value = this.settings.mapStyle || 'light'
}
// Sync globe projection toggle
if (controller.hasGlobeToggleTarget) {
controller.globeToggleTarget.checked = this.settings.globeProjection || false
}
// Sync fog of war settings
const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]')
if (fogRadiusInput) {
fogRadiusInput.value = this.settings.fogOfWarRadius || 1000
if (controller.hasFogRadiusValueTarget) {
controller.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m`
}
}
const fogThresholdInput = controller.element.querySelector('input[name="fogOfWarThreshold"]')
if (fogThresholdInput) {
fogThresholdInput.value = this.settings.fogOfWarThreshold || 1
if (controller.hasFogThresholdValueTarget) {
controller.fogThresholdValueTarget.textContent = fogThresholdInput.value
}
}
// Sync route generation settings
const metersBetweenInput = controller.element.querySelector('input[name="metersBetweenRoutes"]')
if (metersBetweenInput) {
metersBetweenInput.value = this.settings.metersBetweenRoutes || 500
if (controller.hasMetersBetweenValueTarget) {
controller.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m`
}
}
const minutesBetweenInput = controller.element.querySelector('input[name="minutesBetweenRoutes"]')
if (minutesBetweenInput) {
minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60
if (controller.hasMinutesBetweenValueTarget) {
controller.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min`
}
}
// Sync speed-colored routes settings
if (controller.hasSpeedColorScaleInputTarget) {
const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
controller.speedColorScaleInputTarget.value = colorScale
}
if (controller.hasSpeedColorScaleContainerTarget && controller.hasSpeedColoredToggleTarget) {
const isEnabled = controller.speedColoredToggleTarget.checked
controller.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled)
}
// Sync points rendering mode radio buttons
const pointsRenderingRadios = controller.element.querySelectorAll('input[name="pointsRenderingMode"]')
pointsRenderingRadios.forEach(radio => {
radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw')
})
// Sync speed-colored routes toggle
const speedColoredRoutesToggle = controller.element.querySelector('input[name="speedColoredRoutes"]')
if (speedColoredRoutesToggle) {
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
}
}
/**
* Update map style from settings
*/
async updateMapStyle(event) {
const styleName = event.target.value
SettingsManager.updateSetting('mapStyle', styleName)
const style = await getMapStyle(styleName)
// Clear layer references
this.layerManager.clearLayerReferences()
this.map.setStyle(style)
// Reload layers after style change
this.map.once('style.load', () => {
this.controller.loadMapData()
})
}
/**
* Reset settings to defaults
*/
resetSettings() {
if (confirm('Reset all settings to defaults? This will reload the page.')) {
SettingsManager.resetToDefaults()
window.location.reload()
}
}
/**
* Toggle globe projection
* Requires page reload to apply since projection is set at map initialization
*/
async toggleGlobe(event) {
const enabled = event.target.checked
await SettingsManager.updateSetting('globeProjection', enabled)
Toast.info('Globe view will be applied after page reload')
// Prompt user to reload
if (confirm('Globe view requires a page reload to take effect. Reload now?')) {
window.location.reload()
}
}
/**
* Update route opacity in real-time
*/
updateRouteOpacity(event) {
const opacity = parseInt(event.target.value) / 100
const routesLayer = this.layerManager.getLayer('routes')
if (routesLayer && this.map.getLayer('routes')) {
this.map.setPaintProperty('routes', 'line-opacity', opacity)
}
SettingsManager.updateSetting('routeOpacity', opacity)
}
/**
* Update advanced settings from form submission
*/
async updateAdvancedSettings(event) {
event.preventDefault()
const formData = new FormData(event.target)
const settings = {
routeOpacity: parseFloat(formData.get('routeOpacity')) / 100,
fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')),
fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')),
metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')),
minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')),
pointsRenderingMode: formData.get('pointsRenderingMode'),
speedColoredRoutes: formData.get('speedColoredRoutes') === 'on'
}
// Apply settings to current map
await this.applySettingsToMap(settings)
// Save to backend
for (const [key, value] of Object.entries(settings)) {
await SettingsManager.updateSetting(key, value)
}
// Update controller settings and dataLoader
this.controller.settings = { ...this.controller.settings, ...settings }
if (this.controller.dataLoader) {
this.controller.dataLoader.updateSettings(this.controller.settings)
}
Toast.success('Settings updated successfully')
}
/**
* Apply settings to map without reload
*/
async applySettingsToMap(settings) {
// Update route opacity
if (settings.routeOpacity !== undefined) {
const routesLayer = this.layerManager.getLayer('routes')
if (routesLayer && this.map.getLayer('routes')) {
this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity)
}
}
// Update fog of war settings
if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) {
const fogLayer = this.layerManager.getLayer('fog')
if (fogLayer) {
if (settings.fogOfWarRadius) {
fogLayer.clearRadius = settings.fogOfWarRadius
}
// Redraw fog layer if it has data and is visible
if (fogLayer.visible && fogLayer.data) {
await fogLayer.update(fogLayer.data)
}
}
}
// For settings that require data reload
if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) {
Toast.info('Reloading map data with new settings...')
await this.controller.loadMapData()
}
}
// Display value update methods
updateFogRadiusDisplay(event) {
if (this.controller.hasFogRadiusValueTarget) {
this.controller.fogRadiusValueTarget.textContent = `${event.target.value}m`
}
}
updateFogThresholdDisplay(event) {
if (this.controller.hasFogThresholdValueTarget) {
this.controller.fogThresholdValueTarget.textContent = event.target.value
}
}
updateMetersBetweenDisplay(event) {
if (this.controller.hasMetersBetweenValueTarget) {
this.controller.metersBetweenValueTarget.textContent = `${event.target.value}m`
}
}
updateMinutesBetweenDisplay(event) {
if (this.controller.hasMinutesBetweenValueTarget) {
this.controller.minutesBetweenValueTarget.textContent = `${event.target.value}min`
}
}
}

View file

@ -0,0 +1,153 @@
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Manages visits-related operations for Maps V2
* Including visit creation, filtering, and layer management
*/
export class VisitsManager {
constructor(controller) {
this.controller = controller
this.layerManager = controller.layerManager
this.filterManager = controller.filterManager
this.api = controller.api
this.dataLoader = controller.dataLoader
}
/**
* Toggle visits layer
*/
toggleVisits(event) {
const enabled = event.target.checked
SettingsManager.updateSetting('visitsEnabled', enabled)
const visitsLayer = this.layerManager.getLayer('visits')
if (visitsLayer) {
if (enabled) {
visitsLayer.show()
if (this.controller.hasVisitsSearchTarget) {
this.controller.visitsSearchTarget.style.display = 'block'
}
} else {
visitsLayer.hide()
if (this.controller.hasVisitsSearchTarget) {
this.controller.visitsSearchTarget.style.display = 'none'
}
}
}
}
/**
* Search visits
*/
searchVisits(event) {
const searchTerm = event.target.value.toLowerCase()
const visitsLayer = this.layerManager.getLayer('visits')
this.filterManager.filterAndUpdateVisits(
searchTerm,
this.filterManager.getCurrentVisitFilter(),
visitsLayer
)
}
/**
* Filter visits by status
*/
filterVisits(event) {
const filter = event.target.value
this.filterManager.setCurrentVisitFilter(filter)
const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || ''
const visitsLayer = this.layerManager.getLayer('visits')
this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer)
}
/**
* Start create visit mode
*/
startCreateVisit() {
console.log('[Maps V2] Starting create visit mode')
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
this.controller.toggleSettings()
}
this.controller.map.getCanvas().style.cursor = 'crosshair'
Toast.info('Click on the map to place a visit')
this.handleCreateVisitClick = (e) => {
const { lng, lat } = e.lngLat
this.openVisitCreationModal(lat, lng)
this.controller.map.getCanvas().style.cursor = ''
}
this.controller.map.once('click', this.handleCreateVisitClick)
}
/**
* Open visit creation modal
*/
openVisitCreationModal(lat, lng) {
console.log('[Maps V2] Opening visit creation modal', { lat, lng })
const modalElement = document.querySelector('[data-controller="visit-creation-v2"]')
if (!modalElement) {
console.error('[Maps V2] Visit creation modal not found')
Toast.error('Visit creation modal not available')
return
}
const controller = this.controller.application.getControllerForElementAndIdentifier(
modalElement,
'visit-creation-v2'
)
if (controller) {
controller.open(lat, lng, this.controller)
} else {
console.error('[Maps V2] Visit creation controller not found')
Toast.error('Visit creation controller not available')
}
}
/**
* Handle visit creation event - reload visits and update layer
*/
async handleVisitCreated(event) {
console.log('[Maps V2] Visit created, reloading visits...', event.detail)
try {
const visits = await this.api.fetchVisits({
start_at: this.controller.startDateValue,
end_at: this.controller.endDateValue
})
console.log('[Maps V2] Fetched visits:', visits.length)
this.filterManager.setAllVisits(visits)
const visitsGeoJSON = this.dataLoader.visitsToGeoJSON(visits)
console.log('[Maps V2] Converted to GeoJSON:', visitsGeoJSON.features.length, 'features')
const visitsLayer = this.layerManager.getLayer('visits')
if (visitsLayer) {
visitsLayer.update(visitsGeoJSON)
console.log('[Maps V2] Visits layer updated successfully')
} else {
console.warn('[Maps V2] Visits layer not found, cannot update')
}
} catch (error) {
console.error('[Maps V2] Failed to reload visits:', error)
}
}
/**
* Handle visit update event - reload visits and update layer
*/
async handleVisitUpdated(event) {
console.log('[Maps V2] Visit updated, reloading visits...', event.detail)
// Reuse the same logic as creation
await this.handleVisitCreated(event)
}
}

View file

@ -0,0 +1,649 @@
import { Controller } from '@hotwired/stimulus'
import { ApiClient } from 'maps_maplibre/services/api_client'
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
import { SearchManager } from 'maps_maplibre/utils/search_manager'
import { Toast } from 'maps_maplibre/components/toast'
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
import { CleanupHelper } from 'maps_maplibre/utils/cleanup_helper'
import { MapInitializer } from './maplibre/map_initializer'
import { MapDataManager } from './maplibre/map_data_manager'
import { LayerManager } from './maplibre/layer_manager'
import { DataLoader } from './maplibre/data_loader'
import { EventHandlers } from './maplibre/event_handlers'
import { FilterManager } from './maplibre/filter_manager'
import { DateManager } from './maplibre/date_manager'
import { SettingsController } from './maplibre/settings_manager'
import { AreaSelectionManager } from './maplibre/area_selection_manager'
import { VisitsManager } from './maplibre/visits_manager'
import { PlacesManager } from './maplibre/places_manager'
import { RoutesManager } from './maplibre/routes_manager'
/**
* Main map controller for Maps V2
* Coordinates between different managers and handles UI interactions
*/
export default class extends Controller {
static values = {
apiKey: String,
startDate: String,
endDate: String,
timezone: String
}
static targets = [
'container',
'loading',
'loadingText',
'monthSelect',
'clusterToggle',
'settingsPanel',
'visitsSearch',
'routeOpacityRange',
'placesFilters',
'enableAllPlaceTagsToggle',
'fogRadiusValue',
'fogThresholdValue',
'metersBetweenValue',
'minutesBetweenValue',
// Search
'searchInput',
'searchResults',
// Layer toggles
'pointsToggle',
'routesToggle',
'heatmapToggle',
'visitsToggle',
'photosToggle',
'areasToggle',
'placesToggle',
'fogToggle',
'scratchToggle',
'familyToggle',
// Speed-colored routes
'routesOptions',
'speedColoredToggle',
'speedColorScaleContainer',
'speedColorScaleInput',
// Globe projection
'globeToggle',
// Family members
'familyMembersList',
'familyMembersContainer',
// Area selection
'selectAreaButton',
'selectionActions',
'deleteButtonText',
'selectedVisitsContainer',
'selectedVisitsBulkActions',
// Info display
'infoDisplay',
'infoTitle',
'infoContent',
'infoActions'
]
async connect() {
this.cleanup = new CleanupHelper()
// Initialize API and settings
SettingsManager.initialize(this.apiKeyValue)
this.settingsController = new SettingsController(this)
await this.settingsController.loadSettings()
this.settings = this.settingsController.settings
// Sync toggle states with loaded settings
this.settingsController.syncToggleStates()
await this.initializeMap()
this.initializeAPI()
// Initialize managers
this.layerManager = new LayerManager(this.map, this.settings, this.api)
this.dataLoader = new DataLoader(this.api, this.apiKeyValue, this.settings)
this.eventHandlers = new EventHandlers(this.map, this)
this.filterManager = new FilterManager(this.dataLoader)
this.mapDataManager = new MapDataManager(this)
// Initialize feature managers
this.areaSelectionManager = new AreaSelectionManager(this)
this.visitsManager = new VisitsManager(this)
this.placesManager = new PlacesManager(this)
this.routesManager = new RoutesManager(this)
// Initialize search manager
this.initializeSearch()
// Listen for visit and place creation/update events
this.boundHandleVisitCreated = this.visitsManager.handleVisitCreated.bind(this.visitsManager)
this.cleanup.addEventListener(document, 'visit:created', this.boundHandleVisitCreated)
this.boundHandleVisitUpdated = this.visitsManager.handleVisitUpdated.bind(this.visitsManager)
this.cleanup.addEventListener(document, 'visit:updated', this.boundHandleVisitUpdated)
this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager)
this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated)
this.boundHandlePlaceUpdated = this.placesManager.handlePlaceUpdated.bind(this.placesManager)
this.cleanup.addEventListener(document, 'place:updated', this.boundHandlePlaceUpdated)
this.boundHandleAreaCreated = this.handleAreaCreated.bind(this)
this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated)
// Format initial dates
this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue))
this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue))
console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue)
this.loadMapData()
}
disconnect() {
this.searchManager?.destroy()
this.cleanup.cleanup()
this.map?.remove()
performanceMonitor.logReport()
}
/**
* Initialize MapLibre map
*/
async initializeMap() {
this.map = await MapInitializer.initialize(this.containerTarget, {
mapStyle: this.settings.mapStyle,
globeProjection: this.settings.globeProjection
})
}
/**
* Initialize API client
*/
initializeAPI() {
this.api = new ApiClient(this.apiKeyValue)
}
/**
* Initialize location search
*/
initializeSearch() {
if (!this.hasSearchInputTarget || !this.hasSearchResultsTarget) {
console.warn('[Maps V2] Search targets not found, search functionality disabled')
return
}
this.searchManager = new SearchManager(this.map, this.apiKeyValue)
this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget)
console.log('[Maps V2] Search manager initialized')
}
/**
* Load map data from API
*/
async loadMapData(options = {}) {
return this.mapDataManager.loadMapData(
this.startDateValue,
this.endDateValue,
{
...options,
onProgress: this.updateLoadingProgress.bind(this)
}
)
}
/**
* Month selector changed
*/
monthChanged(event) {
const { startDate, endDate } = DateManager.parseMonthSelector(event.target.value)
this.startDateValue = startDate
this.endDateValue = endDate
console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue)
this.loadMapData()
}
/**
* Show loading indicator
*/
showLoading() {
this.loadingTarget.classList.remove('hidden')
}
/**
* Hide loading indicator
*/
hideLoading() {
this.loadingTarget.classList.add('hidden')
}
/**
* Update loading progress
*/
updateLoadingProgress({ loaded, totalPages, progress }) {
if (this.hasLoadingTextTarget) {
const percentage = Math.round(progress * 100)
this.loadingTextTarget.textContent = `Loading... ${percentage}%`
}
}
/**
* Toggle settings panel
*/
toggleSettings() {
if (this.hasSettingsPanelTarget) {
this.settingsPanelTarget.classList.toggle('open')
}
}
// ===== Delegated Methods to Managers =====
// Settings Controller methods
updateMapStyle(event) { return this.settingsController.updateMapStyle(event) }
resetSettings() { return this.settingsController.resetSettings() }
updateRouteOpacity(event) { return this.settingsController.updateRouteOpacity(event) }
updateAdvancedSettings(event) { return this.settingsController.updateAdvancedSettings(event) }
updateFogRadiusDisplay(event) { return this.settingsController.updateFogRadiusDisplay(event) }
updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) }
updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) }
updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) }
toggleGlobe(event) { return this.settingsController.toggleGlobe(event) }
// Area Selection Manager methods
startSelectArea() { return this.areaSelectionManager.startSelectArea() }
cancelAreaSelection() { return this.areaSelectionManager.cancelAreaSelection() }
deleteSelectedPoints() { return this.areaSelectionManager.deleteSelectedPoints() }
// Visits Manager methods
toggleVisits(event) { return this.visitsManager.toggleVisits(event) }
searchVisits(event) { return this.visitsManager.searchVisits(event) }
filterVisits(event) { return this.visitsManager.filterVisits(event) }
startCreateVisit() { return this.visitsManager.startCreateVisit() }
// Places Manager methods
togglePlaces(event) { return this.placesManager.togglePlaces(event) }
filterPlacesByTags(event) { return this.placesManager.filterPlacesByTags(event) }
toggleAllPlaceTags(event) { return this.placesManager.toggleAllPlaceTags(event) }
startCreatePlace() { return this.placesManager.startCreatePlace() }
// Area creation
startCreateArea() {
console.log('[Maps V2] Starting create area mode')
if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) {
this.toggleSettings()
}
// Find area drawer controller on the same element
const drawerController = this.application.getControllerForElementAndIdentifier(
this.element,
'area-drawer'
)
if (drawerController) {
console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map)
drawerController.startDrawing(this.map)
} else {
console.error('[Maps V2] Area drawer controller not found')
Toast.error('Area drawer controller not available')
}
}
async handleAreaCreated(event) {
console.log('[Maps V2] Area created:', event.detail.area)
try {
// Fetch all areas from API
const areas = await this.api.fetchAreas()
console.log('[Maps V2] Fetched areas:', areas.length)
// Convert to GeoJSON
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features')
if (areasGeoJSON.features.length > 0) {
console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2))
}
// Get or create the areas layer
let areasLayer = this.layerManager.getLayer('areas')
console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible)
if (areasLayer) {
// Update existing layer
areasLayer.update(areasGeoJSON)
console.log('[Maps V2] Areas layer updated')
} else {
// Create the layer if it doesn't exist yet
console.log('[Maps V2] Creating areas layer')
this.layerManager._addAreasLayer(areasGeoJSON)
areasLayer = this.layerManager.getLayer('areas')
console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible)
}
// Enable the layer if it wasn't already
if (areasLayer) {
if (!areasLayer.visible) {
console.log('[Maps V2] Showing areas layer')
areasLayer.show()
this.settings.layers.areas = true
this.settingsController.saveSetting('layers.areas', true)
// Update toggle state
if (this.hasAreasToggleTarget) {
this.areasToggleTarget.checked = true
}
} else {
console.log('[Maps V2] Areas layer already visible')
}
}
Toast.success('Area created successfully!')
} catch (error) {
console.error('[Maps V2] Failed to reload areas:', error)
Toast.error('Failed to reload areas')
}
}
// Routes Manager methods
togglePoints(event) { return this.routesManager.togglePoints(event) }
toggleRoutes(event) { return this.routesManager.toggleRoutes(event) }
toggleHeatmap(event) { return this.routesManager.toggleHeatmap(event) }
toggleFog(event) { return this.routesManager.toggleFog(event) }
toggleScratch(event) { return this.routesManager.toggleScratch(event) }
togglePhotos(event) { return this.routesManager.togglePhotos(event) }
toggleAreas(event) { return this.routesManager.toggleAreas(event) }
toggleTracks(event) { return this.routesManager.toggleTracks(event) }
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
toggleFamily(event) { return this.routesManager.toggleFamily(event) }
// Family Members methods
async loadFamilyMembers() {
try {
const response = await fetch(`/api/v1/families/locations?api_key=${this.apiKeyValue}`, {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
})
if (!response.ok) {
if (response.status === 403) {
console.warn('[Maps V2] Family feature not enabled or user not in family')
Toast.info('Family feature not available')
return
}
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
const locations = data.locations || []
// Update family layer with locations
const familyLayer = this.layerManager.getLayer('family')
if (familyLayer) {
familyLayer.loadMembers(locations)
}
// Render family members list
this.renderFamilyMembersList(locations)
Toast.success(`Loaded ${locations.length} family member(s)`)
} catch (error) {
console.error('[Maps V2] Failed to load family members:', error)
Toast.error('Failed to load family members')
}
}
renderFamilyMembersList(locations) {
if (!this.hasFamilyMembersContainerTarget) return
const container = this.familyMembersContainerTarget
if (locations.length === 0) {
container.innerHTML = '<p class="text-xs text-base-content/60">No family members sharing location</p>'
return
}
container.innerHTML = locations.map(location => {
const emailInitial = location.email?.charAt(0)?.toUpperCase() || '?'
const color = this.getFamilyMemberColor(location.user_id)
const lastSeen = new Date(location.updated_at).toLocaleString('en-US', {
timeZone: this.timezoneValue || 'UTC',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
return `
<div class="flex items-center gap-2 p-2 hover:bg-base-200 rounded-lg cursor-pointer transition-colors"
data-action="click->maps--maplibre#centerOnFamilyMember"
data-member-id="${location.user_id}">
<div style="background-color: ${color}; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: bold; flex-shrink: 0;">
${emailInitial}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">${location.email || 'Unknown'}</div>
<div class="text-xs text-base-content/60">${lastSeen}</div>
</div>
</div>
`
}).join('')
}
getFamilyMemberColor(userId) {
const colors = [
'#3b82f6', '#10b981', '#f59e0b',
'#ef4444', '#8b5cf6', '#ec4899'
]
// Use user ID to get consistent color
const hash = userId.toString().split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
return colors[hash % colors.length]
}
centerOnFamilyMember(event) {
const memberId = event.currentTarget.dataset.memberId
if (!memberId) return
const familyLayer = this.layerManager.getLayer('family')
if (familyLayer) {
familyLayer.centerOnMember(parseInt(memberId))
Toast.success('Centered on family member')
}
}
// Info Display methods
showInfo(title, content, actions = []) {
if (!this.hasInfoDisplayTarget) return
// Set title
this.infoTitleTarget.textContent = title
// Set content
this.infoContentTarget.innerHTML = content
// Set actions
if (actions.length > 0) {
this.infoActionsTarget.innerHTML = actions.map(action => {
if (action.type === 'button') {
// For button actions (modals, etc.), create a button with data-action
// Use error styling for delete buttons
const buttonClass = action.label === 'Delete' ? 'btn btn-sm btn-error' : 'btn btn-sm btn-primary'
return `<button class="${buttonClass}" data-action="click->maps--maplibre#${action.handler}" data-id="${action.id}" data-entity-type="${action.entityType}">${action.label}</button>`
} else {
// For link actions, keep the original behavior
return `<a href="${action.url}" class="btn btn-sm btn-primary">${action.label}</a>`
}
}).join('')
} else {
this.infoActionsTarget.innerHTML = ''
}
// Show info display
this.infoDisplayTarget.classList.remove('hidden')
// Switch to tools tab and open panel
this.switchToToolsTab()
}
closeInfo() {
if (!this.hasInfoDisplayTarget) return
this.infoDisplayTarget.classList.add('hidden')
}
/**
* Handle edit action from info display
*/
handleEdit(event) {
const button = event.currentTarget
const id = button.dataset.id
const entityType = button.dataset.entityType
console.log('[Maps V2] Opening edit for', entityType, id)
switch (entityType) {
case 'visit':
this.openVisitModal(id)
break
case 'place':
this.openPlaceEditModal(id)
break
default:
console.warn('[Maps V2] Unknown entity type:', entityType)
}
}
/**
* Handle delete action from info display
*/
handleDelete(event) {
const button = event.currentTarget
const id = button.dataset.id
const entityType = button.dataset.entityType
console.log('[Maps V2] Deleting', entityType, id)
switch (entityType) {
case 'area':
this.deleteArea(id)
break
default:
console.warn('[Maps V2] Unknown entity type for delete:', entityType)
}
}
/**
* Open visit edit modal
*/
async openVisitModal(visitId) {
try {
// Fetch visit details
const response = await fetch(`/api/v1/visits/${visitId}`, {
headers: {
'Authorization': `Bearer ${this.apiKeyValue}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to fetch visit: ${response.status}`)
}
const visit = await response.json()
// Trigger visit edit event
const event = new CustomEvent('visit:edit', {
detail: { visit },
bubbles: true
})
document.dispatchEvent(event)
} catch (error) {
console.error('[Maps V2] Failed to load visit:', error)
Toast.error('Failed to load visit details')
}
}
/**
* Delete area with confirmation
*/
async deleteArea(areaId) {
try {
// Fetch area details
const area = await this.api.fetchArea(areaId)
// Show delete confirmation
const confirmed = confirm(`Delete area "${area.name}"?\n\nThis action cannot be undone.`)
if (!confirmed) return
Toast.info('Deleting area...')
// Delete the area
await this.api.deleteArea(areaId)
// Reload areas
const areas = await this.api.fetchAreas()
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
const areasLayer = this.layerManager.getLayer('areas')
if (areasLayer) {
areasLayer.update(areasGeoJSON)
}
// Close info display
this.closeInfo()
Toast.success('Area deleted successfully')
} catch (error) {
console.error('[Maps V2] Failed to delete area:', error)
Toast.error('Failed to delete area')
}
}
/**
* Open place edit modal
*/
async openPlaceEditModal(placeId) {
try {
// Fetch place details
const response = await fetch(`/api/v1/places/${placeId}`, {
headers: {
'Authorization': `Bearer ${this.apiKeyValue}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Failed to fetch place: ${response.status}`)
}
const place = await response.json()
// Trigger place edit event
const event = new CustomEvent('place:edit', {
detail: { place },
bubbles: true
})
document.dispatchEvent(event)
} catch (error) {
console.error('[Maps V2] Failed to load place:', error)
Toast.error('Failed to load place details')
}
}
switchToToolsTab() {
// Open the panel if it's not already open
if (!this.settingsPanelTarget.classList.contains('open')) {
this.toggleSettings()
}
// Find the map-panel controller and switch to tools tab
const panelElement = this.settingsPanelTarget
const panelController = this.application.getControllerForElementAndIdentifier(panelElement, 'map-panel')
if (panelController && panelController.switchToTab) {
panelController.switchToTab('tools')
}
}
}

View file

@ -0,0 +1,323 @@
import { Controller } from '@hotwired/stimulus'
import { createMapChannel } from 'maps_maplibre/channels/map_channel'
import { WebSocketManager } from 'maps_maplibre/utils/websocket_manager'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Real-time controller
* Manages ActionCable connection and real-time updates
*/
export default class extends Controller {
static targets = ['liveModeToggle']
static values = {
enabled: { type: Boolean, default: true },
liveMode: { type: Boolean, default: false }
}
connect() {
console.log('[Realtime Controller] Connecting...')
if (!this.enabledValue) {
console.log('[Realtime Controller] Disabled, skipping setup')
return
}
try {
this.connectedChannels = new Set()
this.liveModeEnabled = false // Start with live mode disabled
// Delay channel setup to ensure ActionCable is ready
// This prevents race condition with page initialization
setTimeout(() => {
try {
this.setupChannels()
} catch (error) {
console.error('[Realtime Controller] Failed to setup channels in setTimeout:', error)
this.updateConnectionIndicator(false)
}
}, 1000)
// Initialize toggle state from settings
if (this.hasLiveModeToggleTarget) {
this.liveModeToggleTarget.checked = this.liveModeEnabled
}
} catch (error) {
console.error('[Realtime Controller] Failed to initialize:', error)
// Don't throw - allow page to continue loading
}
}
disconnect() {
this.channels?.unsubscribeAll()
}
/**
* Setup ActionCable channels
* Family channel is always enabled when family feature is on
* Points channel (live mode) is controlled by user toggle
*/
setupChannels() {
try {
console.log('[Realtime Controller] Setting up channels...')
this.channels = createMapChannel({
connected: this.handleConnected.bind(this),
disconnected: this.handleDisconnected.bind(this),
received: this.handleReceived.bind(this),
enableLiveMode: this.liveModeEnabled // Control points channel
})
console.log('[Realtime Controller] Channels setup complete')
} catch (error) {
console.error('[Realtime Controller] Failed to setup channels:', error)
console.error('[Realtime Controller] Error stack:', error.stack)
this.updateConnectionIndicator(false)
// Don't throw - page should continue to work
}
}
/**
* Toggle live mode (new points appearing in real-time)
*/
toggleLiveMode(event) {
this.liveModeEnabled = event.target.checked
// Update recent point layer visibility
this.updateRecentPointLayerVisibility()
// Reconnect channels with new settings
if (this.channels) {
this.channels.unsubscribeAll()
}
this.setupChannels()
const message = this.liveModeEnabled ? 'Live mode enabled' : 'Live mode disabled'
Toast.info(message)
}
/**
* Update recent point layer visibility based on live mode state
*/
updateRecentPointLayerVisibility() {
const mapsController = this.mapsV2Controller
if (!mapsController) {
return
}
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
if (!recentPointLayer) {
return
}
if (this.liveModeEnabled) {
recentPointLayer.show()
} else {
recentPointLayer.hide()
recentPointLayer.clear()
}
}
/**
* Handle connection
*/
handleConnected(channelName) {
this.connectedChannels.add(channelName)
// Only show toast when at least one channel is connected
if (this.connectedChannels.size === 1) {
Toast.success('Connected to real-time updates')
this.updateConnectionIndicator(true)
}
}
/**
* Handle disconnection
*/
handleDisconnected(channelName) {
this.connectedChannels.delete(channelName)
// Show warning only when all channels are disconnected
if (this.connectedChannels.size === 0) {
Toast.warning('Disconnected from real-time updates')
this.updateConnectionIndicator(false)
}
}
/**
* Handle received data
*/
handleReceived(data) {
switch (data.type) {
case 'new_point':
this.handleNewPoint(data.point)
break
case 'family_location':
this.handleFamilyLocation(data.member)
break
case 'notification':
this.handleNotification(data.notification)
break
}
}
/**
* Get the maps--maplibre controller (on same element)
*/
get mapsV2Controller() {
const element = this.element
const app = this.application
return app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
}
/**
* Handle new point
* Point data is broadcast as: [lat, lon, battery, altitude, timestamp, velocity, id, country_name]
*/
handleNewPoint(pointData) {
const mapsController = this.mapsV2Controller
if (!mapsController) {
console.warn('[Realtime Controller] Maps controller not found')
return
}
console.log('[Realtime Controller] Received point data:', pointData)
// Parse point data from array format
const [lat, lon, battery, altitude, timestamp, velocity, id, countryName] = pointData
// Get points layer from layer manager
const pointsLayer = mapsController.layerManager?.getLayer('points')
if (!pointsLayer) {
console.warn('[Realtime Controller] Points layer not found')
return
}
// Get current data
const currentData = pointsLayer.data || { type: 'FeatureCollection', features: [] }
const features = [...(currentData.features || [])]
// Add new point
features.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [parseFloat(lon), parseFloat(lat)]
},
properties: {
id: parseInt(id),
latitude: parseFloat(lat),
longitude: parseFloat(lon),
battery: parseFloat(battery) || null,
altitude: parseFloat(altitude) || null,
timestamp: timestamp,
velocity: parseFloat(velocity) || null,
country_name: countryName || null
}
})
// Update layer with new data
pointsLayer.update({
type: 'FeatureCollection',
features
})
console.log('[Realtime Controller] Added new point to map:', id)
// Update recent point marker (always visible in live mode)
this.updateRecentPoint(parseFloat(lon), parseFloat(lat), {
id: parseInt(id),
battery: parseFloat(battery) || null,
altitude: parseFloat(altitude) || null,
timestamp: timestamp,
velocity: parseFloat(velocity) || null,
country_name: countryName || null
})
// Zoom to the new point
this.zoomToPoint(parseFloat(lon), parseFloat(lat))
Toast.info('New location recorded')
}
/**
* Handle family member location update
*/
handleFamilyLocation(member) {
const mapsController = this.mapsV2Controller
if (!mapsController) return
const familyLayer = mapsController.familyLayer
if (familyLayer) {
familyLayer.updateMember(member)
}
}
/**
* Handle notification
*/
handleNotification(notification) {
Toast.info(notification.message || 'New notification')
}
/**
* Update the recent point marker
* This marker is always visible in live mode, independent of points layer visibility
*/
updateRecentPoint(longitude, latitude, properties = {}) {
const mapsController = this.mapsV2Controller
if (!mapsController) {
console.warn('[Realtime Controller] Maps controller not found')
return
}
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
if (!recentPointLayer) {
console.warn('[Realtime Controller] Recent point layer not found')
return
}
// Show the layer if live mode is enabled and update with new point
if (this.liveModeEnabled) {
recentPointLayer.show()
recentPointLayer.updateRecentPoint(longitude, latitude, properties)
console.log('[Realtime Controller] Updated recent point marker:', longitude, latitude)
}
}
/**
* Zoom map to a specific point
*/
zoomToPoint(longitude, latitude) {
const mapsController = this.mapsV2Controller
if (!mapsController || !mapsController.map) {
console.warn('[Realtime Controller] Map not available for zooming')
return
}
const map = mapsController.map
// Fly to the new point with a smooth animation
map.flyTo({
center: [longitude, latitude],
zoom: Math.max(map.getZoom(), 14), // Zoom to at least level 14, or keep current zoom if higher
duration: 2000, // 2 second animation
essential: true // This animation is considered essential with respect to prefers-reduced-motion
})
console.log('[Realtime Controller] Zoomed to point:', longitude, latitude)
}
/**
* Update connection indicator
*/
updateConnectionIndicator(connected) {
const indicator = document.querySelector('.connection-indicator')
if (indicator) {
// Show the indicator when connection is attempted
indicator.classList.add('active')
indicator.classList.toggle('connected', connected)
indicator.classList.toggle('disconnected', !connected)
}
}
}

View file

@ -1,6 +1,7 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import "leaflet.heat";
import "leaflet.control.layers.tree";
import consumer from "../channels/consumer";
import { createMarkersArray } from "../maps/markers";
@ -37,6 +38,8 @@ import { countryCodesMap } from "../maps/country_codes";
import { VisitsManager } from "../maps/visits";
import { ScratchLayer } from "../maps/scratch_layer";
import { LocationSearch } from "../maps/location_search";
import { PlacesManager } from "../maps/places";
import { PrivacyZoneManager } from "../maps/privacy_zones";
import "leaflet-draw";
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
@ -44,7 +47,11 @@ import { TileMonitor } from "../maps/tile_monitor";
import BaseController from "./base_controller";
import { createAllMapLayers } from "../maps/layers";
import { applyThemeToControl, applyThemeToButton, applyThemeToPanel } from "../maps/theme_utils";
import { addTopRightButtons } from "../maps/map_controls";
import {
addTopRightButtons,
setCreatePlaceButtonActive,
setCreatePlaceButtonInactive
} from "../maps/map_controls";
export default class extends BaseController {
static targets = ["container"];
@ -57,7 +64,7 @@ export default class extends BaseController {
tracksVisible = false;
tracksSubscription = null;
connect() {
async connect() {
super.connect();
console.log("Map controller connected");
@ -110,8 +117,22 @@ export default class extends BaseController {
this.markers = [];
}
// Set default center (Berlin) if no markers available
this.center = this.markers.length > 0 ? this.markers[this.markers.length - 1] : [52.514568, 13.350111];
// Set default center based on priority: Home place > last marker > Berlin
let defaultCenter = [52.514568, 13.350111]; // Berlin as final fallback
// Try to get Home place coordinates
try {
const homeCoords = this.element.dataset.home_coordinates ?
JSON.parse(this.element.dataset.home_coordinates) : null;
if (homeCoords && Array.isArray(homeCoords) && homeCoords.length === 2) {
defaultCenter = homeCoords;
}
} catch (error) {
console.warn('Error parsing home coordinates:', error);
}
// Use last marker if available, otherwise use default center (Home or Berlin)
this.center = this.markers.length > 0 ? this.markers[this.markers.length - 1] : defaultCenter;
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 14);
@ -158,6 +179,12 @@ export default class extends BaseController {
this.map.setMaxBounds(bounds);
// Initialize privacy zone manager
this.privacyZoneManager = new PrivacyZoneManager(this.map, this.apiKey);
// Load privacy zones and apply filtering BEFORE creating map layers
await this.initializePrivacyZones();
this.markersArray = createMarkersArray(this.markers, this.userSettings, this.apiKey);
this.markersLayer = L.layerGroup(this.markersArray);
this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
@ -213,6 +240,18 @@ export default class extends BaseController {
// Expose visits manager globally for location search integration
window.visitsManager = this.visitsManager;
// Initialize the places manager
this.placesManager = new PlacesManager(this.map, this.apiKey);
this.placesManager.initialize();
// Parse user tags for places layer control
try {
this.userTags = this.element.dataset.user_tags ? JSON.parse(this.element.dataset.user_tags) : [];
} catch (error) {
console.error('Error parsing user tags:', error);
this.userTags = [];
}
// Expose maps controller globally for family integration
window.mapsController = this;
@ -229,9 +268,6 @@ export default class extends BaseController {
}
this.switchRouteMode('routes', true);
// Initialize layers based on settings
this.initializeLayersFromSettings();
// Listen for Family Members layer becoming ready
this.setupFamilyLayerListener();
@ -247,21 +283,12 @@ export default class extends BaseController {
// Add all top-right buttons in the correct order
this.initializeTopRightButtons();
// Initialize layers for the layer control
const controlsLayer = {
Points: this.markersLayer,
Routes: this.polylinesLayer,
Tracks: this.tracksLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer,
Photos: this.photoMarkers,
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer()
};
// Initialize tree-based layer control (must be before initializeLayersFromSettings)
this.layerControl = this.createTreeLayerControl();
this.map.addControl(this.layerControl);
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Initialize layers based on settings (must be after tree control creation)
this.initializeLayersFromSettings();
// Initialize Live Map Handler
@ -441,6 +468,144 @@ export default class extends BaseController {
return maps;
}
createTreeLayerControl(additionalLayers = {}) {
// Build base maps tree structure
const baseMapsTree = {
label: 'Map Styles',
children: []
};
const maps = this.baseMaps();
Object.entries(maps).forEach(([name, layer]) => {
baseMapsTree.children.push({
label: name,
layer: layer
});
});
// Build places subtree with tags
// Store filtered layers for later restoration
if (!this.placesFilteredLayers) {
this.placesFilteredLayers = {};
}
// Store mapping of tag IDs to layers for persistence
if (!this.tagLayerMapping) {
this.tagLayerMapping = {};
}
// Create Untagged layer
const untaggedLayer = this.placesManager?.createFilteredLayer([]) || L.layerGroup();
this.placesFilteredLayers['Untagged'] = untaggedLayer;
// Store layer reference with special ID for untagged
untaggedLayer._placeTagId = 'untagged';
const placesChildren = [
{
label: 'Untagged',
layer: untaggedLayer
}
];
// Add individual tag layers
if (this.userTags && this.userTags.length > 0) {
this.userTags.forEach(tag => {
const icon = tag.icon || '📍';
const label = `${icon} #${tag.name}`;
const tagLayer = this.placesManager?.createFilteredLayer([tag.id]) || L.layerGroup();
this.placesFilteredLayers[label] = tagLayer;
// Store tag ID on the layer itself for easy identification
tagLayer._placeTagId = tag.id;
// Store in mapping for lookup by ID
this.tagLayerMapping[tag.id] = { layer: tagLayer, label: label };
placesChildren.push({
label: label,
layer: tagLayer
});
});
}
// Build visits subtree
const visitsChildren = [
{
label: 'Suggested',
layer: this.visitsManager?.getVisitCirclesLayer() || L.layerGroup()
},
{
label: 'Confirmed',
layer: this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
}
];
// Build the overlays tree structure
const overlaysTree = {
label: 'Layers',
selectAllCheckbox: false,
children: [
{
label: 'Points',
layer: this.markersLayer
},
{
label: 'Routes',
layer: this.polylinesLayer
},
{
label: 'Tracks',
layer: this.tracksLayer
},
{
label: 'Heatmap',
layer: this.heatmapLayer
},
{
label: 'Fog of War',
layer: this.fogOverlay
},
{
label: 'Scratch map',
layer: this.scratchLayerManager?.getLayer() || L.layerGroup()
},
{
label: 'Areas',
layer: this.areasLayer
},
{
label: 'Photos',
layer: this.photoMarkers
},
{
label: 'Visits',
selectAllCheckbox: true,
children: visitsChildren
},
{
label: 'Places',
selectAllCheckbox: true,
children: placesChildren
}
]
};
// Add Family Members layer if available
if (additionalLayers['Family Members']) {
overlaysTree.children.push({
label: 'Family Members',
layer: additionalLayers['Family Members']
});
}
// Create the tree control
return L.control.layers.tree(
baseMapsTree,
overlaysTree,
{
namedToggle: false,
collapsed: true,
position: 'topright'
}
);
}
removeEventListeners() {
document.removeEventListener('click', this.handleDeleteClick);
}
@ -471,6 +636,21 @@ export default class extends BaseController {
// Add event listeners for overlay layer changes to keep routes/tracks selector in sync
this.map.on('overlayadd', (event) => {
// Track place tag layer restoration
if (this.isRestoringLayers && event.layer && this.placesFilteredLayers) {
// Check if this is a place tag layer being restored
const isPlaceTagLayer = Object.values(this.placesFilteredLayers).includes(event.layer);
if (isPlaceTagLayer && this.restoredPlaceTagLayers !== undefined) {
const tagId = event.layer._placeTagId;
this.restoredPlaceTagLayers.add(tagId);
// Check if all expected place tag layers have been restored
if (this.restoredPlaceTagLayers.size >= this.expectedPlaceTagLayerCount) {
this.isRestoringLayers = false;
}
}
}
// Save enabled layers whenever a layer is added (unless we're restoring from settings)
if (!this.isRestoringLayers) {
this.saveEnabledLayers();
@ -505,7 +685,7 @@ export default class extends BaseController {
endDate: endDate,
userSettings: this.userSettings
});
} else if (event.name === 'Suggested Visits' || event.name === 'Confirmed Visits') {
} else if (event.name === 'Suggested' || event.name === 'Confirmed') {
// Load visits when layer is enabled
console.log(`${event.name} layer enabled via layer control`);
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
@ -548,9 +728,9 @@ export default class extends BaseController {
if (this.drawControl && this.map._controlCorners.topleft.querySelector('.leaflet-draw')) {
this.map.removeControl(this.drawControl);
}
} else if (event.name === 'Suggested Visits') {
} else if (event.name === 'Suggested') {
// Clear suggested visits when layer is disabled
console.log('Suggested Visits layer disabled via layer control');
console.log('Suggested layer disabled via layer control');
if (this.visitsManager) {
// Clear the visit circles when layer is disabled
this.visitsManager.visitCircles.clearLayers();
@ -566,6 +746,15 @@ export default class extends BaseController {
this.fogOverlay = null;
}
});
// Listen for place creation events to disable creation mode
document.addEventListener('place:created', () => {
this.disablePlaceCreationMode();
});
document.addEventListener('place:create:cancelled', () => {
this.disablePlaceCreationMode();
});
}
updatePreferredBaseLayer(selectedLayerName) {
@ -592,14 +781,17 @@ export default class extends BaseController {
}
saveEnabledLayers() {
const enabledLayers = [];
const layerNames = [
'Points', 'Routes', 'Tracks', 'Heatmap', 'Fog of War',
'Scratch map', 'Areas', 'Photos', 'Suggested Visits', 'Confirmed Visits',
'Family Members'
];
// Don't save if we're restoring layers from settings
if (this.isRestoringLayers) {
console.log('[saveEnabledLayers] Skipping save - currently restoring layers from settings');
return;
}
const controlsLayer = {
const enabledLayers = [];
// Iterate through all layers on the map to determine which are enabled
// This is more reliable than parsing the DOM
const layersToCheck = {
'Points': this.markersLayer,
'Routes': this.polylinesLayer,
'Tracks': this.tracksLayer,
@ -608,18 +800,29 @@ export default class extends BaseController {
'Scratch map': this.scratchLayerManager?.getLayer(),
'Areas': this.areasLayer,
'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Suggested': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Family Members': window.familyMembersController?.familyMarkersLayer
};
layerNames.forEach(name => {
const layer = controlsLayer[name];
// Check standard layers
Object.entries(layersToCheck).forEach(([name, layer]) => {
if (layer && this.map.hasLayer(layer)) {
enabledLayers.push(name);
}
});
// Check place tag layers - save as "place_tag:ID" format
if (this.placesFilteredLayers) {
Object.values(this.placesFilteredLayers).forEach(layer => {
if (layer && this.map.hasLayer(layer) && layer._placeTagId !== undefined) {
enabledLayers.push(`place_tag:${layer._placeTagId}`);
}
});
} else {
console.warn('[saveEnabledLayers] placesFilteredLayers is not initialized');
}
fetch('/api/v1/settings', {
method: 'PATCH',
headers: {
@ -636,7 +839,7 @@ export default class extends BaseController {
.then((data) => {
if (data.status === 'success') {
console.log('Enabled layers saved:', enabledLayers);
showFlashMessage('notice', 'Map layer preferences saved');
// showFlashMessage('notice', 'Map layer preferences saved');
} else {
console.error('Failed to save enabled layers:', data.message);
showFlashMessage('error', `Failed to save layer preferences: ${data.message}`);
@ -693,16 +896,8 @@ export default class extends BaseController {
// Update the layer control
if (this.layerControl) {
this.map.removeControl(this.layerControl);
const controlsLayer = {
Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.layerGroup(),
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup()
};
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
this.layerControl = this.createTreeLayerControl();
this.map.addControl(this.layerControl);
}
// Update heatmap
@ -955,100 +1150,141 @@ export default class extends BaseController {
// Form HTML
div.innerHTML = `
<form id="settings-form" style="overflow-y: auto; max-height: 70vh; width: 12rem; padding-right: 5px;">
<label for="route-opacity">Route Opacity, %</label>
<div class="join">
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="route-opacity" name="route_opacity" min="10" max="100" step="10" value="${Math.round(this.routeOpacity * 100)}">
<label for="route_opacity_info" class="btn-xs join-item ">?</label>
<form id="settings-form" class="space-y-3">
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Route Opacity, %</span>
</label>
<div class="join join-horizontal w-full">
<input type="number" class="input input-bordered input-sm join-item flex-1" id="route-opacity" name="route_opacity" min="10" max="100" step="10" value="${Math.round(this.routeOpacity * 100)}">
<label for="route_opacity_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
</div>
<label for="fog_of_war_meters">Fog of War radius</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_meters" name="fog_of_war_meters" min="5" max="200" step="1" value="${this.clearFogRadius}">
<label for="fog_of_war_meters_info" class="btn-xs join-item">?</label>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Fog of War radius</span>
</label>
<div class="join join-horizontal w-full">
<input type="number" class="input input-bordered input-sm join-item flex-1" id="fog_of_war_meters" name="fog_of_war_meters" min="5" max="200" step="1" value="${this.clearFogRadius}">
<label for="fog_of_war_meters_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
</div>
<label for="fog_of_war_threshold">Seconds between Fog of War lines</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_threshold" name="fog_of_war_threshold" step="1" value="${this.userSettings.fog_of_war_threshold}">
<label for="fog_of_war_threshold_info" class="btn-xs join-item">?</label>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Fog of War threshold</span>
</label>
<div class="join join-horizontal w-full">
<input type="number" class="input input-bordered input-sm join-item flex-1" id="fog_of_war_threshold" name="fog_of_war_threshold" step="1" value="${this.userSettings.fog_of_war_threshold}">
<label for="fog_of_war_threshold_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
</div>
<label for="meters_between_routes">Meters between routes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="meters_between_routes" name="meters_between_routes" step="1" value="${this.userSettings.meters_between_routes}">
<label for="meters_between_routes_info" class="btn-xs join-item">?</label>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Meters between routes</span>
</label>
<div class="join join-horizontal w-full">
<input type="number" class="input input-bordered input-sm join-item flex-1" id="meters_between_routes" name="meters_between_routes" step="1" value="${this.userSettings.meters_between_routes}">
<label for="meters_between_routes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
</div>
<label for="minutes_between_routes">Minutes between routes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="minutes_between_routes" name="minutes_between_routes" step="1" value="${this.userSettings.minutes_between_routes}">
<label for="minutes_between_routes_info" class="btn-xs join-item">?</label>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Minutes between routes</span>
</label>
<div class="join join-horizontal w-full">
<input type="number" class="input input-bordered input-sm join-item flex-1" id="minutes_between_routes" name="minutes_between_routes" step="1" value="${this.userSettings.minutes_between_routes}">
<label for="minutes_between_routes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
</div>
<label for="time_threshold_minutes">Time threshold minutes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="time_threshold_minutes" name="time_threshold_minutes" step="1" value="${this.userSettings.time_threshold_minutes}">
<label for="time_threshold_minutes_info" class="btn-xs join-item">?</label>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Time threshold minutes</span>
</label>
<div class="join join-horizontal w-full">
<input type="number" class="input input-bordered input-sm join-item flex-1" id="time_threshold_minutes" name="time_threshold_minutes" step="1" value="${this.userSettings.time_threshold_minutes}">
<label for="time_threshold_minutes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
</div>
<label for="merge_threshold_minutes">Merge threshold minutes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="merge_threshold_minutes" name="merge_threshold_minutes" step="1" value="${this.userSettings.merge_threshold_minutes}">
<label for="merge_threshold_minutes_info" class="btn-xs join-item">?</label>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Merge threshold minutes</span>
</label>
<div class="join join-horizontal w-full">
<input type="number" class="input input-bordered input-sm join-item flex-1" id="merge_threshold_minutes" name="merge_threshold_minutes" step="1" value="${this.userSettings.merge_threshold_minutes}">
<label for="merge_threshold_minutes_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
</div>
<label for="points_rendering_mode">
Points rendering mode
<label for="points_rendering_mode_info" class="btn-xs join-item inline">?</label>
</label>
<label for="raw">
<input type="radio" id="raw" name="points_rendering_mode" class='w-4' style="width: 20px;" value="raw" ${this.pointsRenderingModeChecked('raw')} />
Raw
</label>
<label for="simplified">
<input type="radio" id="simplified" name="points_rendering_mode" class='w-4' style="width: 20px;" value="simplified" ${this.pointsRenderingModeChecked('simplified')}/>
Simplified
</label>
<label for="live_map_enabled">
Live Map
<label for="live_map_enabled_info" class="btn-xs join-item inline">?</label>
<input type="checkbox" id="live_map_enabled" name="live_map_enabled" class='w-4' style="width: 20px;" value="false" ${this.liveMapEnabledChecked(true)} />
</label>
<label for="speed_colored_routes">
Speed-colored routes
<label for="speed_colored_routes_info" class="btn-xs join-item inline">?</label>
<input type="checkbox" id="speed_colored_routes" name="speed_colored_routes" class='w-4' style="width: 20px;" ${this.speedColoredRoutesChecked()} />
</label>
<label for="speed_color_scale">Speed color scale</label>
<div class="join">
<input type="text" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="speed_color_scale" name="speed_color_scale" min="5" max="100" step="1" value="${this.speedColorScale}">
<label for="speed_color_scale_info" class="btn-xs join-item">?</label>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Points rendering mode</span>
<label for="points_rendering_mode_info" class="btn btn-xs btn-ghost cursor-pointer">?</label>
</label>
<div class="flex flex-col gap-2">
<label class="label cursor-pointer justify-start gap-2 py-1">
<input type="radio" id="raw" name="points_rendering_mode" class="radio radio-sm" value="raw" ${this.pointsRenderingModeChecked('raw')} />
<span class="label-text text-xs">Raw</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-1">
<input type="radio" id="simplified" name="points_rendering_mode" class="radio radio-sm" value="simplified" ${this.pointsRenderingModeChecked('simplified')} />
<span class="label-text text-xs">Simplified</span>
</label>
</div>
</div>
<button type="button" id="edit-gradient-btn" class="btn btn-xs mt-2">Edit Scale</button>
<hr>
<div class="form-control">
<label class="label cursor-pointer py-1">
<span class="label-text text-xs">Live Map</span>
<div class="flex items-center gap-1">
<label for="live_map_enabled_info" class="btn btn-xs btn-ghost cursor-pointer">?</label>
<input type="checkbox" id="live_map_enabled" name="live_map_enabled" class="checkbox checkbox-sm" ${this.liveMapEnabledChecked(true)} />
</div>
</label>
</div>
<button type="submit" class="btn btn-xs mt-2">Update</button>
<div class="form-control">
<label class="label cursor-pointer py-1">
<span class="label-text text-xs">Speed-colored routes</span>
<div class="flex items-center gap-1">
<label for="speed_colored_routes_info" class="btn btn-xs btn-ghost cursor-pointer">?</label>
<input type="checkbox" id="speed_colored_routes" name="speed_colored_routes" class="checkbox checkbox-sm" ${this.speedColoredRoutesChecked()} />
</div>
</label>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Speed color scale</span>
</label>
<div class="join join-horizontal w-full">
<input type="text" class="input input-bordered input-sm join-item flex-1" id="speed_color_scale" name="speed_color_scale" value="${this.speedColorScale}">
<label for="speed_color_scale_info" class="btn btn-sm btn-ghost join-item cursor-pointer">?</label>
</div>
<button type="button" id="edit-gradient-btn" class="btn btn-sm mt-2 w-full">Edit Colors</button>
</div>
<div class="divider my-2"></div>
<button type="submit" class="btn btn-sm btn-primary w-full">Update</button>
</form>
`;
// Style the panel with theme-aware styling
applyThemeToPanel(div, this.userTheme);
div.style.padding = '10px';
div.style.width = '220px';
div.style.maxHeight = 'calc(60vh - 20px)';
div.style.overflowY = 'auto';
// Prevent map interactions when interacting with the form
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
// Attach event listener to the "Edit Gradient" button:
const editBtn = div.querySelector("#edit-gradient-btn");
@ -1233,7 +1469,8 @@ export default class extends BaseController {
};
// Re-add the layer control in the same position
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
this.layerControl = this.createTreeLayerControl();
this.map.addControl(this.layerControl);
// Restore layer visibility states
Object.entries(layerStates).forEach(([name, wasVisible]) => {
@ -1274,7 +1511,7 @@ export default class extends BaseController {
initializeTopRightButtons() {
// Add all top-right buttons in the correct order:
// 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer
// 1. Select Area, 2. Add Visit, 3. Create Place, 4. Open Calendar, 5. Open Drawer
// Note: Layer control is added separately and appears at the top
this.topRightControls = addTopRightButtons(
@ -1283,6 +1520,7 @@ export default class extends BaseController {
onSelectArea: () => this.visitsManager.toggleSelectionMode(),
// onAddVisit is intentionally null - the add_visit_controller will attach its handler
onAddVisit: null,
onCreatePlace: () => this.togglePlaceCreationMode(),
onToggleCalendar: () => this.toggleRightPanel(),
onToggleDrawer: () => this.visitsManager.toggleDrawer()
},
@ -1476,6 +1714,7 @@ export default class extends BaseController {
const enabledLayers = this.userSettings.enabled_map_layers || ['Points', 'Routes', 'Heatmap'];
console.log('Initializing layers from settings:', enabledLayers);
// Standard layers mapping
const controlsLayer = {
'Points': this.markersLayer,
'Routes': this.polylinesLayer,
@ -1485,12 +1724,12 @@ export default class extends BaseController {
'Scratch map': this.scratchLayerManager?.getLayer(),
'Areas': this.areasLayer,
'Photos': this.photoMarkers,
'Suggested Visits': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed Visits': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Suggested': this.visitsManager?.getVisitCirclesLayer(),
'Confirmed': this.visitsManager?.getConfirmedVisitCirclesLayer(),
'Family Members': window.familyMembersController?.familyMarkersLayer
};
// Apply saved layer preferences
// Apply saved layer preferences for standard layers
Object.entries(controlsLayer).forEach(([name, layer]) => {
if (!layer) {
if (enabledLayers.includes(name)) {
@ -1531,7 +1770,7 @@ export default class extends BaseController {
});
} else if (name === 'Fog of War') {
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
} else if (name === 'Suggested Visits' || name === 'Confirmed Visits') {
} else if (name === 'Suggested' || name === 'Confirmed') {
if (this.visitsManager && typeof this.visitsManager.fetchAndDisplayVisits === 'function') {
this.visitsManager.fetchAndDisplayVisits();
}
@ -1559,6 +1798,88 @@ export default class extends BaseController {
console.log(`Disabled layer: ${name}`);
}
});
// Place tag layers will be restored by updateTreeControlCheckboxes
// which triggers the tree control's change events to properly add/remove layers
// Track expected place tag layers to be restored
const expectedPlaceTagLayers = enabledLayers.filter(key => key.startsWith('place_tag:'));
this.restoredPlaceTagLayers = new Set();
this.expectedPlaceTagLayerCount = expectedPlaceTagLayers.length;
// Set flag to prevent saving during layer restoration
this.isRestoringLayers = true;
// Update the tree control checkboxes to reflect the layer states
// The tree control will handle adding/removing layers when checkboxes change
// Wait a bit for the tree control to be fully initialized
setTimeout(() => {
this.updateTreeControlCheckboxes(enabledLayers);
// Set a fallback timeout in case not all layers get added
setTimeout(() => {
if (this.isRestoringLayers) {
console.warn('[initializeLayersFromSettings] Timeout reached, forcing restoration complete');
this.isRestoringLayers = false;
}
}, 2000);
}, 200);
}
updateTreeControlCheckboxes(enabledLayers) {
const layerControl = document.querySelector('.leaflet-control-layers');
if (!layerControl) {
console.log('Layer control not found, skipping checkbox update');
return;
}
// Extract place tag IDs from enabledLayers
const enabledTagIds = new Set();
enabledLayers.forEach(key => {
if (key.startsWith('place_tag:')) {
const tagId = key.replace('place_tag:', '');
enabledTagIds.add(tagId === 'untagged' ? 'untagged' : parseInt(tagId));
}
});
// Find and check/uncheck all layer checkboxes based on saved state
const inputs = layerControl.querySelectorAll('input[type="checkbox"]');
inputs.forEach(input => {
const label = input.closest('label') || input.nextElementSibling;
if (label) {
const layerName = label.textContent.trim();
// Check if this is a standard layer
let shouldBeEnabled = enabledLayers.includes(layerName);
// Also check if this is a place tag layer
let placeLayer = null;
if (this.placesFilteredLayers) {
placeLayer = this.placesFilteredLayers[layerName];
if (placeLayer && placeLayer._placeTagId !== undefined) {
// This is a place tag layer - check if it should be enabled
const placeLayerEnabled = enabledTagIds.has(placeLayer._placeTagId);
if (placeLayerEnabled) {
shouldBeEnabled = true;
}
}
}
// Skip group headers that might have checkboxes
if (layerName && !layerName.includes('Map Styles') && !layerName.includes('Layers')) {
if (shouldBeEnabled !== input.checked) {
// Checkbox state needs to change - simulate a click to trigger tree control
// The tree control listens for click events, not change events
input.click();
} else if (shouldBeEnabled && placeLayer && !this.map.hasLayer(placeLayer)) {
// Checkbox is already checked but layer isn't on map (edge case)
// This can happen if the checkbox was checked in HTML but layer wasn't added
// Manually add the layer since clicking won't help (checkbox is already checked)
placeLayer.addTo(this.map);
}
}
}
});
}
setupFamilyLayerListener() {
@ -1899,6 +2220,7 @@ export default class extends BaseController {
return;
}
const timezone = this.timezone || 'UTC';
const html = citiesData.map(country => `
<div class="mb-4" style="min-width: min-content;">
<h4 class="font-bold text-md">${country.country}</h4>
@ -1907,7 +2229,7 @@ export default class extends BaseController {
<li class="text-sm whitespace-nowrap">
${city.city}
<span class="text-gray-500">
(${new Date(city.timestamp * 1000).toLocaleDateString()})
(${new Date(city.timestamp * 1000).toLocaleDateString('en-US', { timeZone: timezone })})
</span>
</li>
`).join('')}
@ -2108,72 +2430,73 @@ export default class extends BaseController {
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);
}
});
// Re-add the layer control with additional layers
this.layerControl = this.createTreeLayerControl(additionalLayers);
this.map.addControl(this.layerControl);
}
togglePlaceCreationMode() {
if (!this.placesManager) {
console.warn("Places manager not initialized");
return;
}
const button = document.getElementById('create-place-btn');
if (this.placesManager.creationMode) {
// Disable creation mode
this.placesManager.disableCreationMode();
if (button) {
setCreatePlaceButtonInactive(button, this.userTheme);
button.setAttribute('data-tip', 'Create a place');
}
} else {
// Enable creation mode
this.placesManager.enableCreationMode();
if (button) {
setCreatePlaceButtonActive(button);
button.setAttribute('data-tip', 'Click map to place marker (click to cancel)');
}
}
}
disablePlaceCreationMode() {
if (!this.placesManager) {
return;
}
// Only disable if currently in creation mode
if (this.placesManager.creationMode) {
this.placesManager.disableCreationMode();
const button = document.getElementById('create-place-btn');
if (button) {
setCreatePlaceButtonInactive(button, this.userTheme);
button.setAttribute('data-tip', 'Create a place');
}
}
}
async initializePrivacyZones() {
try {
await this.privacyZoneManager.loadPrivacyZones();
if (this.privacyZoneManager.hasPrivacyZones()) {
console.log(`[Privacy Zones] Loaded ${this.privacyZoneManager.getZoneCount()} zones covering ${this.privacyZoneManager.getTotalPlacesCount()} places`);
// Apply filtering to markers BEFORE they're rendered
this.markers = this.privacyZoneManager.filterPoints(this.markers);
// Apply filtering to tracks if they exist
if (this.tracksData && Array.isArray(this.tracksData)) {
this.tracksData = this.privacyZoneManager.filterTracks(this.tracksData);
}
}
} catch (error) {
console.error('[Privacy Zones] Error initializing privacy zones:', error);
}
}
}

View file

@ -0,0 +1,291 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", "noteInput",
"nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton",
"modalTitle", "submitButton", "placeIdInput"]
static values = {
apiKey: String
}
connect() {
this.setupEventListeners()
this.currentRadius = 0.5 // Start with 500m (0.5km)
this.maxRadius = 1.5 // Max 1500m (1.5km)
this.setupTagListeners()
this.editingPlaceId = null
}
setupEventListeners() {
document.addEventListener('place:create', (e) => {
this.open(e.detail.latitude, e.detail.longitude)
})
document.addEventListener('place:edit', (e) => {
this.openForEdit(e.detail.place)
})
}
setupTagListeners() {
// Listen for checkbox changes to update badge styling
if (this.hasTagCheckboxesTarget) {
this.tagCheckboxesTarget.addEventListener('change', (e) => {
if (e.target.type === 'checkbox' && e.target.name === 'tag_ids[]') {
const badge = e.target.nextElementSibling
const color = badge.dataset.color
if (e.target.checked) {
// Filled style
badge.classList.remove('badge-outline')
badge.style.backgroundColor = color
badge.style.borderColor = color
badge.style.color = 'white'
} else {
// Outline style
badge.classList.add('badge-outline')
badge.style.backgroundColor = 'transparent'
badge.style.borderColor = color
badge.style.color = color
}
}
})
}
}
async open(latitude, longitude) {
this.editingPlaceId = null
this.latitudeInputTarget.value = latitude
this.longitudeInputTarget.value = longitude
this.currentRadius = 0.5 // Reset radius when opening modal
// Update modal for creation mode
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Create New Place'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Create Place'
}
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
await this.loadNearbyPlaces(latitude, longitude)
}
async openForEdit(place) {
this.editingPlaceId = place.id
this.currentRadius = 0.5
// Fill in form with place data
this.nameInputTarget.value = place.name
this.latitudeInputTarget.value = place.latitude
this.longitudeInputTarget.value = place.longitude
if (this.hasNoteInputTarget && place.note) {
this.noteInputTarget.value = place.note
}
// Update modal for edit mode
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Edit Place'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Update Place'
}
// Check the appropriate tag checkboxes
const tagCheckboxes = this.formTarget.querySelectorAll('input[name="tag_ids[]"]')
tagCheckboxes.forEach(checkbox => {
const isSelected = place.tags.some(tag => tag.id === parseInt(checkbox.value))
checkbox.checked = isSelected
// Trigger change event to update badge styling
const event = new Event('change', { bubbles: true })
checkbox.dispatchEvent(event)
})
this.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus()
// Load nearby places for suggestions
await this.loadNearbyPlaces(place.latitude, place.longitude)
}
close() {
this.modalTarget.classList.remove('modal-open')
this.formTarget.reset()
this.nearbyListTarget.innerHTML = ''
this.loadMoreContainerTarget.classList.add('hidden')
this.currentRadius = 0.5
this.editingPlaceId = null
const event = new CustomEvent('place:create:cancelled')
document.dispatchEvent(event)
}
async loadNearbyPlaces(latitude, longitude, radius = null) {
this.loadingSpinnerTarget.classList.remove('hidden')
// Use provided radius or current radius
const searchRadius = radius || this.currentRadius
const isLoadingMore = radius !== null && radius > this.currentRadius - 0.5
// Only clear the list on initial load, not when loading more
if (!isLoadingMore) {
this.nearbyListTarget.innerHTML = ''
}
try {
const response = await fetch(
`/api/v1/places/nearby?latitude=${latitude}&longitude=${longitude}&radius=${searchRadius}&limit=5`,
{ headers: { 'Authorization': `Bearer ${this.apiKeyValue}` } }
)
if (!response.ok) throw new Error('Failed to load nearby places')
const data = await response.json()
this.renderNearbyPlaces(data.places, isLoadingMore)
// Show load more button if we can expand radius further
if (searchRadius < this.maxRadius) {
this.loadMoreContainerTarget.classList.remove('hidden')
this.updateLoadMoreButton(searchRadius)
} else {
this.loadMoreContainerTarget.classList.add('hidden')
}
} catch (error) {
console.error('Error loading nearby places:', error)
this.nearbyListTarget.innerHTML = '<p class="text-error">Failed to load suggestions</p>'
} finally {
this.loadingSpinnerTarget.classList.add('hidden')
}
}
renderNearbyPlaces(places, append = false) {
if (!places || places.length === 0) {
if (!append) {
this.nearbyListTarget.innerHTML = '<p class="text-sm text-gray-500">No nearby places found</p>'
}
return
}
// Calculate starting index based on existing items
const currentCount = append ? this.nearbyListTarget.querySelectorAll('.card').length : 0
const html = places.map((place, index) => `
<div class="card card-compact bg-base-200 cursor-pointer hover:bg-base-300 transition"
data-action="click->place-creation#selectNearby"
data-place-name="${this.escapeHtml(place.name)}"
data-place-latitude="${place.latitude}"
data-place-longitude="${place.longitude}">
<div class="card-body">
<div class="flex gap-2">
<span class="badge badge-primary badge-sm">#${currentCount + index + 1}</span>
<div class="flex-1">
<h4 class="font-semibold">${this.escapeHtml(place.name)}</h4>
${place.street ? `<p class="text-sm">${this.escapeHtml(place.street)}</p>` : ''}
${place.city ? `<p class="text-xs text-gray-500">${this.escapeHtml(place.city)}, ${this.escapeHtml(place.country || '')}</p>` : ''}
</div>
</div>
</div>
</div>
`).join('')
if (append) {
this.nearbyListTarget.insertAdjacentHTML('beforeend', html)
} else {
this.nearbyListTarget.innerHTML = html
}
}
async loadMore() {
// Increase radius by 500m (0.5km) up to max of 1500m (1.5km)
if (this.currentRadius >= this.maxRadius) return
this.currentRadius = Math.min(this.currentRadius + 0.5, this.maxRadius)
const latitude = parseFloat(this.latitudeInputTarget.value)
const longitude = parseFloat(this.longitudeInputTarget.value)
await this.loadNearbyPlaces(latitude, longitude, this.currentRadius)
}
updateLoadMoreButton(currentRadius) {
const nextRadius = Math.min(currentRadius + 0.5, this.maxRadius)
const radiusInMeters = Math.round(nextRadius * 1000)
this.loadMoreButtonTarget.textContent = `Load More (search up to ${radiusInMeters}m)`
}
selectNearby(event) {
const element = event.currentTarget
this.nameInputTarget.value = element.dataset.placeName
this.latitudeInputTarget.value = element.dataset.placeLatitude
this.longitudeInputTarget.value = element.dataset.placeLongitude
}
async submit(event) {
event.preventDefault()
const formData = new FormData(this.formTarget)
const tagIds = Array.from(this.formTarget.querySelectorAll('input[name="tag_ids[]"]:checked'))
.map(cb => cb.value)
const payload = {
place: {
name: formData.get('name'),
latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')),
note: formData.get('note') || null,
source: 'manual',
tag_ids: tagIds
}
}
try {
const isEdit = this.editingPlaceId !== null
const url = isEdit ? `/api/v1/places/${this.editingPlaceId}` : '/api/v1/places'
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`
},
body: JSON.stringify(payload)
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.errors?.join(', ') || `Failed to ${isEdit ? 'update' : 'create'} place`)
}
const place = await response.json()
this.close()
this.showNotification(`Place ${isEdit ? 'updated' : 'created'} successfully!`, 'success')
const eventName = isEdit ? 'place:updated' : 'place:created'
const customEvent = new CustomEvent(eventName, { detail: { place } })
document.dispatchEvent(customEvent)
} catch (error) {
console.error(`Error ${this.editingPlaceId ? 'updating' : 'creating'} place:`, error)
this.showNotification(error.message, 'error')
}
}
showNotification(message, type = 'info') {
const event = new CustomEvent('notification:show', {
detail: { message, type },
bubbles: true
})
document.dispatchEvent(event)
}
escapeHtml(text) {
if (!text) return ''
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
}

View file

@ -0,0 +1,41 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
console.log("Places filter controller connected");
}
filterPlaces(event) {
// Get reference to the maps controller's placesManager
const mapsController = window.mapsController;
if (!mapsController || !mapsController.placesManager) {
console.warn("Maps controller or placesManager not found");
return;
}
// Collect all checked tag IDs
const checkboxes = this.element.querySelectorAll('input[type="checkbox"][data-tag-id]');
const selectedTagIds = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => parseInt(cb.dataset.tagId));
console.log("Filtering places by tags:", selectedTagIds);
// Filter places by selected tags (or show all if none selected)
mapsController.placesManager.filterByTags(selectedTagIds.length > 0 ? selectedTagIds : null);
}
clearAll(event) {
event.preventDefault();
// Uncheck all checkboxes
const checkboxes = this.element.querySelectorAll('input[type="checkbox"][data-tag-id]');
checkboxes.forEach(cb => cb.checked = false);
// Show all places
const mapsController = window.mapsController;
if (mapsController && mapsController.placesManager) {
mapsController.placesManager.filterByTags(null);
}
}
}

View file

@ -0,0 +1,30 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["toggle", "radiusInput", "slider", "field", "label"]
toggleRadius(event) {
if (event.target.checked) {
// Enable privacy zone
this.radiusInputTarget.classList.remove('hidden')
// Set default value if not already set
if (!this.fieldTarget.value || this.fieldTarget.value === '') {
const defaultValue = 1000
this.fieldTarget.value = defaultValue
this.sliderTarget.value = defaultValue
this.labelTarget.textContent = `${defaultValue}m`
}
} else {
// Disable privacy zone
this.radiusInputTarget.classList.add('hidden')
this.fieldTarget.value = ''
}
}
updateFromSlider(event) {
const value = event.target.value
this.fieldTarget.value = value
this.labelTarget.textContent = `${value}m`
}
}

View file

@ -10,7 +10,8 @@ export default class extends BaseController {
uuid: String,
dataBounds: Object,
hexagonsAvailable: Boolean,
selfHosted: String
selfHosted: String,
timezone: String
};
connect() {
@ -72,9 +73,7 @@ export default class extends BaseController {
}
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
@ -94,9 +93,6 @@ export default class extends BaseController {
// 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');
}
// Load hexagons only if they are pre-calculated and data exists
@ -138,7 +134,6 @@ export default class extends BaseController {
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
@ -187,7 +182,6 @@ export default class extends BaseController {
}
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) {
@ -210,7 +204,6 @@ export default class extends BaseController {
const loadingElement = document.getElementById('map-loading');
if (loadingElement) {
loadingElement.style.display = 'none';
console.log('🚫 Loading overlay hidden - hexagons are fully loaded');
}
}
}
@ -255,10 +248,11 @@ export default class extends BaseController {
}
buildPopupContent(props) {
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString() : 'N/A';
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString() : 'N/A';
const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString() : '';
const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString() : '';
const timezone = this.timezoneValue || 'UTC';
const startDate = props.earliest_point ? new Date(props.earliest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A';
const endDate = props.latest_point ? new Date(props.latest_point).toLocaleDateString('en-US', { timeZone: timezone }) : 'N/A';
const startTime = props.earliest_point ? new Date(props.earliest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : '';
const endTime = props.latest_point ? new Date(props.latest_point).toLocaleTimeString('en-US', { timeZone: timezone }) : '';
return `
<div style="font-size: 12px; line-height: 1.6; max-width: 300px;">

View file

@ -0,0 +1,184 @@
import { Controller } from '@hotwired/stimulus'
/**
* Speed Color Editor Controller
* Manages the gradient editor modal for speed-colored routes
*/
export default class extends Controller {
static targets = ['modal', 'stopsList', 'preview']
static values = {
colorStops: String
}
connect() {
this.loadColorStops()
}
loadColorStops() {
const stopsString = this.colorStopsValue || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
this.stops = this.parseColorStops(stopsString)
this.renderStops()
this.updatePreview()
}
parseColorStops(stopsString) {
return stopsString.split('|').map(segment => {
const [speed, color] = segment.split(':')
return { speed: Number(speed), color }
})
}
serializeColorStops() {
return this.stops.map(stop => `${stop.speed}:${stop.color}`).join('|')
}
renderStops() {
if (!this.hasStopsListTarget) return
this.stopsListTarget.innerHTML = this.stops.map((stop, index) => `
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg" data-index="${index}">
<div class="flex-1">
<label class="label">
<span class="label-text text-sm">Speed (km/h)</span>
</label>
<input type="number"
class="input input-bordered input-sm w-full"
value="${stop.speed}"
min="0"
max="200"
data-action="input->speed-color-editor#updateSpeed"
data-index="${index}" />
</div>
<div class="flex-1">
<label class="label">
<span class="label-text text-sm">Color</span>
</label>
<div class="flex gap-2 items-center">
<input type="color"
class="w-12 h-10 rounded cursor-pointer border-2 border-base-300"
value="${stop.color}"
data-action="input->speed-color-editor#updateColor"
data-index="${index}" />
<input type="text"
class="input input-bordered input-sm w-24 font-mono text-xs"
value="${stop.color}"
pattern="^#[0-9A-Fa-f]{6}$"
data-action="input->speed-color-editor#updateColorText"
data-index="${index}" />
</div>
</div>
<button type="button"
class="btn btn-sm btn-ghost btn-circle text-error mt-6"
data-action="click->speed-color-editor#removeStop"
data-index="${index}"
${this.stops.length <= 2 ? 'disabled' : ''}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
`).join('')
}
updateSpeed(event) {
const index = parseInt(event.target.dataset.index)
this.stops[index].speed = Number(event.target.value)
this.updatePreview()
}
updateColor(event) {
const index = parseInt(event.target.dataset.index)
const color = event.target.value
this.stops[index].color = color
// Update text input
const textInput = event.target.parentElement.querySelector('input[type="text"]')
if (textInput) {
textInput.value = color
}
this.updatePreview()
}
updateColorText(event) {
const index = parseInt(event.target.dataset.index)
const color = event.target.value
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
this.stops[index].color = color
// Update color picker
const colorInput = event.target.parentElement.querySelector('input[type="color"]')
if (colorInput) {
colorInput.value = color
}
this.updatePreview()
}
}
addStop() {
// Find a good speed value between existing stops
const lastStop = this.stops[this.stops.length - 1]
const newSpeed = lastStop.speed + 10
this.stops.push({
speed: newSpeed,
color: '#ff0000'
})
// Sort by speed
this.stops.sort((a, b) => a.speed - b.speed)
this.renderStops()
this.updatePreview()
}
removeStop(event) {
const index = parseInt(event.target.dataset.index)
if (this.stops.length > 2) {
this.stops.splice(index, 1)
this.renderStops()
this.updatePreview()
}
}
updatePreview() {
if (!this.hasPreviewTarget) return
const gradient = this.stops.map((stop, index) => {
const percentage = (index / (this.stops.length - 1)) * 100
return `${stop.color} ${percentage}%`
}).join(', ')
this.previewTarget.style.background = `linear-gradient(to right, ${gradient})`
}
save() {
const serialized = this.serializeColorStops()
// Dispatch event with the new color stops
this.dispatch('save', {
detail: { colorStops: serialized }
})
this.close()
}
close() {
if (this.hasModalTarget) {
const checkbox = this.modalTarget.querySelector('.modal-toggle')
if (checkbox) {
checkbox.checked = false
}
}
}
resetToDefault() {
this.colorStopsValue = '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
this.loadColorStops()
}
}

View file

@ -0,0 +1,255 @@
import { Controller } from '@hotwired/stimulus'
import { Toast } from 'maps_maplibre/components/toast'
/**
* Controller for visit creation modal in Maps V2
*/
export default class extends Controller {
static targets = [
'modal',
'form',
'modalTitle',
'nameInput',
'startTimeInput',
'endTimeInput',
'latitudeInput',
'longitudeInput',
'submitButton'
]
static values = {
apiKey: String
}
connect() {
console.log('[Visit Creation V2] Controller connected')
this.marker = null
this.mapController = null
this.editingVisitId = null
this.setupEventListeners()
}
setupEventListeners() {
document.addEventListener('visit:edit', (e) => {
this.openForEdit(e.detail.visit)
})
}
disconnect() {
this.cleanup()
}
/**
* Open the modal with coordinates
*/
open(lat, lng, mapController) {
console.log('[Visit Creation V2] Opening modal', { lat, lng })
this.editingVisitId = null
this.mapController = mapController
this.latitudeInputTarget.value = lat
this.longitudeInputTarget.value = lng
// Set modal title and button for creation
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Create New Visit'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Create Visit'
}
// Set default times
const now = new Date()
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000))
this.startTimeInputTarget.value = this.formatDateTime(now)
this.endTimeInputTarget.value = this.formatDateTime(oneHourLater)
// Show modal
this.modalTarget.classList.add('modal-open')
// Focus on name input
setTimeout(() => this.nameInputTarget.focus(), 100)
// Add marker to map
this.addMarker(lat, lng)
}
/**
* Open the modal for editing an existing visit
*/
openForEdit(visit) {
console.log('[Visit Creation V2] Opening modal for edit', visit)
this.editingVisitId = visit.id
// Set modal title and button for editing
if (this.hasModalTitleTarget) {
this.modalTitleTarget.textContent = 'Edit Visit'
}
if (this.hasSubmitButtonTarget) {
this.submitButtonTarget.textContent = 'Update Visit'
}
// Fill form with visit data
this.nameInputTarget.value = visit.name || ''
this.latitudeInputTarget.value = visit.latitude
this.longitudeInputTarget.value = visit.longitude
// Convert timestamps to datetime-local format
this.startTimeInputTarget.value = this.formatDateTime(new Date(visit.started_at))
this.endTimeInputTarget.value = this.formatDateTime(new Date(visit.ended_at))
// Show modal
this.modalTarget.classList.add('modal-open')
// Focus on name input
setTimeout(() => this.nameInputTarget.focus(), 100)
// Try to get map controller from the maps--maplibre controller
const mapElement = document.querySelector('[data-controller*="maps--maplibre"]')
if (mapElement) {
const app = window.Stimulus || window.Application
this.mapController = app?.getControllerForElementAndIdentifier(mapElement, 'maps--maplibre')
}
// Add marker to map
this.addMarker(visit.latitude, visit.longitude)
}
/**
* Close the modal
*/
close() {
console.log('[Visit Creation V2] Closing modal')
// Hide modal
this.modalTarget.classList.remove('modal-open')
// Reset form
this.formTarget.reset()
// Reset editing state
this.editingVisitId = null
// Remove marker
this.removeMarker()
}
/**
* Handle form submission
*/
async submit(event) {
event.preventDefault()
const isEdit = this.editingVisitId !== null
console.log(`[Visit Creation V2] Submitting form (${isEdit ? 'edit' : 'create'})`)
const formData = new FormData(this.formTarget)
const visitData = {
visit: {
name: formData.get('name'),
started_at: formData.get('started_at'),
ended_at: formData.get('ended_at'),
latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')),
status: 'confirmed'
}
}
try {
const url = isEdit ? `/api/v1/visits/${this.editingVisitId}` : '/api/v1/visits'
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`,
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify(visitData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Failed to ${isEdit ? 'update' : 'create'} visit`)
}
const visit = await response.json()
console.log(`[Visit Creation V2] Visit ${isEdit ? 'updated' : 'created'} successfully`, visit)
// Show success message
this.showToast(`Visit ${isEdit ? 'updated' : 'created'} successfully`, 'success')
// Close modal
this.close()
// Dispatch event to notify map controller
const eventName = isEdit ? 'visit:updated' : 'visit:created'
document.dispatchEvent(new CustomEvent(eventName, {
detail: { visit }
}))
} catch (error) {
console.error(`[Visit Creation V2] Error ${isEdit ? 'updating' : 'creating'} visit:`, error)
this.showToast(error.message || `Failed to ${isEdit ? 'update' : 'create'} visit`, 'error')
}
}
/**
* Add marker to map
*/
addMarker(lat, lng) {
if (!this.mapController) return
// Remove existing marker if any
this.removeMarker()
// Create marker element
const el = document.createElement('div')
el.className = 'visit-creation-marker'
el.innerHTML = '📍'
el.style.fontSize = '30px'
// Use maplibregl if available (from mapController)
const maplibregl = window.maplibregl
if (maplibregl) {
this.marker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat])
.addTo(this.mapController.map)
}
}
/**
* Remove marker from map
*/
removeMarker() {
if (this.marker) {
this.marker.remove()
this.marker = null
}
}
/**
* Clean up resources
*/
cleanup() {
this.removeMarker()
}
/**
* Format date for datetime-local input
*/
formatDateTime(date) {
return date.toISOString().slice(0, 16)
}
/**
* Show toast notification
*/
showToast(message, type = 'info') {
Toast[type](message)
}
}

View file

@ -31,11 +31,14 @@ function createStandardButton(className, svgIcon, title, userTheme, onClickCallb
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
L.DomEvent.disableScrollPropagation(button);
// Attach click handler if provided
// Note: Some buttons (like Add Visit) have their handlers attached separately
if (onClickCallback && typeof onClickCallback === 'function') {
L.DomEvent.on(button, 'click', () => {
L.DomEvent.on(button, 'click', (e) => {
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
onClickCallback(button);
});
}
@ -121,15 +124,35 @@ export function createAddVisitControl(onClickCallback, userTheme = 'dark') {
return AddVisitControl;
}
/**
* Creates a "Create Place" button control for the map
* @param {Function} onClickCallback - Callback function to execute when button is clicked
* @param {String} userTheme - User's theme preference ('dark' or 'light')
* @returns {L.Control} Leaflet control instance
*/
export function createCreatePlaceControl(onClickCallback, userTheme = 'dark') {
const CreatePlaceControl = L.Control.extend({
onAdd: function(map) {
const svgIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-plus"><path d="M19.914 11.105A7.298 7.298 0 0 0 20 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32 32 0 0 0 .824-.738"/><circle cx="12" cy="10" r="3"/><path d="M16 18h6"/><path d="M19 15v6"/></svg>';
const button = createStandardButton('leaflet-control-button create-place-button', svgIcon, 'Create a place', userTheme, onClickCallback);
button.id = 'create-place-btn';
return button;
}
});
return CreatePlaceControl;
}
/**
* Adds all top-right corner buttons to the map in the correct order
* Order: 1. Select Area, 2. Add Visit, 3. Open Calendar, 4. Open Drawer
* Order: 1. Select Area, 2. Add Visit, 3. Create Place, 4. Open Calendar, 5. Open Drawer
* Note: Layer control is added separately by Leaflet and appears at the top
*
* @param {Object} map - Leaflet map instance
* @param {Object} callbacks - Object containing callback functions for each button
* @param {Function} callbacks.onSelectArea - Callback for select area button
* @param {Function} callbacks.onAddVisit - Callback for add visit button
* @param {Function} callbacks.onCreatePlace - Callback for create place button
* @param {Function} callbacks.onToggleCalendar - Callback for toggle calendar/panel button
* @param {Function} callbacks.onToggleDrawer - Callback for toggle drawer button
* @param {String} userTheme - User's theme preference ('dark' or 'light')
@ -151,14 +174,21 @@ export function addTopRightButtons(map, callbacks, userTheme = 'dark') {
controls.addVisitControl = new AddVisitControl({ position: 'topright' });
map.addControl(controls.addVisitControl);
// 3. Open Calendar (Toggle Panel) button
// 3. Create Place button
if (callbacks.onCreatePlace) {
const CreatePlaceControl = createCreatePlaceControl(callbacks.onCreatePlace, userTheme);
controls.createPlaceControl = new CreatePlaceControl({ position: 'topright' });
map.addControl(controls.createPlaceControl);
}
// 4. Open Calendar (Toggle Panel) button
if (callbacks.onToggleCalendar) {
const TogglePanelControl = createTogglePanelControl(callbacks.onToggleCalendar, userTheme);
controls.togglePanelControl = new TogglePanelControl({ position: 'topright' });
map.addControl(controls.togglePanelControl);
}
// 4. Open Drawer button
// 5. Open Drawer button
if (callbacks.onToggleDrawer) {
const DrawerControl = createVisitsDrawerControl(callbacks.onToggleDrawer, userTheme);
controls.drawerControl = new DrawerControl({ position: 'topright' });
@ -191,3 +221,31 @@ export function setAddVisitButtonInactive(button, userTheme = 'dark') {
applyThemeToButton(button, userTheme);
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-check-icon lucide-map-pin-check"><path d="M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728"/><circle cx="12" cy="10" r="3"/><path d="m16 18 2 2 4-4"/></svg>';
}
/**
* Updates the Create Place button to show active state
* @param {HTMLElement} button - The button element to update
*/
export function setCreatePlaceButtonActive(button) {
if (!button) return;
button.style.backgroundColor = '#22c55e';
button.style.color = 'white';
button.style.border = '2px solid #16a34a';
button.style.boxShadow = '0 0 12px rgba(34, 197, 94, 0.5)';
button.innerHTML = '✕';
}
/**
* Updates the Create Place button to show inactive/default state
* @param {HTMLElement} button - The button element to update
* @param {String} userTheme - User's theme preference ('dark' or 'light')
*/
export function setCreatePlaceButtonInactive(button, userTheme = 'dark') {
if (!button) return;
applyThemeToButton(button, userTheme);
button.style.border = '';
button.style.boxShadow = '';
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-plus"><path d="M19.914 11.105A7.298 7.298 0 0 0 20 10a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32 32 0 0 0 .824-.738"/><circle cx="12" cy="10" r="3"/><path d="M16 18h6"/><path d="M19 15v6"/></svg>';
}

View file

@ -0,0 +1,507 @@
// Maps Places Layer Manager
// Handles displaying user places with tag icons and colors on the map
import L from 'leaflet';
import { showFlashMessage } from './helpers';
export class PlacesManager {
constructor(map, apiKey) {
this.map = map;
this.apiKey = apiKey;
this.placesLayer = null;
this.places = [];
this.markers = {};
this.selectedTags = new Set();
this.creationMode = false;
this.creationMarker = null;
}
async initialize() {
this.placesLayer = L.layerGroup();
// Add event listener to reload places when layer is added to map
this.placesLayer.on('add', () => {
this.loadPlaces();
});
await this.loadPlaces();
this.setupMapClickHandler();
this.setupEventListeners();
}
setupEventListeners() {
// Refresh places when a new place is created
document.addEventListener('place:created', async (event) => {
const { place } = event.detail;
// Show success message
showFlashMessage('success', `Place "${place.name}" created successfully!`);
// Add the place to our local array
this.places.push(place);
// Create marker for the new place and add to main layer
const marker = this.createPlaceMarker(place);
if (marker) {
this.markers[place.id] = marker;
marker.addTo(this.placesLayer);
}
// Ensure the main Places layer is visible
this.ensurePlacesLayerVisible();
// Also add to any filtered layers that match this place's tags
this.map.eachLayer((layer) => {
if (layer._tagIds !== undefined) {
// Check if this place's tags match this filtered layer
const placeTagIds = place.tags.map(tag => tag.id);
const layerTagIds = layer._tagIds;
// If it's an untagged layer (empty array) and place has no tags
if (layerTagIds.length === 0 && placeTagIds.length === 0) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
// If place has any tags that match this layer's tags
else if (placeTagIds.some(tagId => layerTagIds.includes(tagId))) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
}
});
});
// Refresh places when a place is updated
document.addEventListener('place:updated', async (event) => {
const { place } = event.detail;
// Show success message
showFlashMessage('success', `Place "${place.name}" updated successfully!`);
// Update the place in our local array
const index = this.places.findIndex(p => p.id === place.id);
if (index !== -1) {
this.places[index] = place;
}
// Remove old marker and add updated one to main layer
if (this.markers[place.id]) {
this.placesLayer.removeLayer(this.markers[place.id]);
}
const marker = this.createPlaceMarker(place);
if (marker) {
this.markers[place.id] = marker;
marker.addTo(this.placesLayer);
}
// Update in all filtered layers
this.map.eachLayer((layer) => {
if (layer._tagIds !== undefined) {
// Remove old marker from this layer
layer.eachLayer((layerMarker) => {
if (layerMarker.options && layerMarker.options.placeId === place.id) {
layer.removeLayer(layerMarker);
}
});
// Check if updated place should be in this layer
const placeTagIds = place.tags.map(tag => tag.id);
const layerTagIds = layer._tagIds;
// If it's an untagged layer (empty array) and place has no tags
if (layerTagIds.length === 0 && placeTagIds.length === 0) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
// If place has any tags that match this layer's tags
else if (placeTagIds.some(tagId => layerTagIds.includes(tagId))) {
const marker = this.createPlaceMarker(place);
if (marker) layer.addLayer(marker);
}
}
});
});
}
async loadPlaces(tagIds = null, untaggedOnly = false) {
try {
const url = new URL('/api/v1/places', window.location.origin);
if (untaggedOnly) {
// Load only untagged places
url.searchParams.append('untagged', 'true');
} else if (tagIds && tagIds.length > 0) {
// Load places with specific tags
tagIds.forEach(id => url.searchParams.append('tag_ids[]', id));
}
// If neither untaggedOnly nor tagIds, load all places
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${this.apiKey}` }
});
if (!response.ok) throw new Error('Failed to load places');
this.places = await response.json();
this.renderPlaces();
} catch (error) {
console.error('Error loading places:', error);
}
}
renderPlaces() {
// Clear existing markers
this.placesLayer.clearLayers();
this.markers = {};
this.places.forEach(place => {
const marker = this.createPlaceMarker(place);
if (marker) {
this.markers[place.id] = marker;
marker.addTo(this.placesLayer);
}
});
}
createPlaceMarker(place) {
if (!place.latitude || !place.longitude) return null;
const icon = this.createPlaceIcon(place);
const marker = L.marker([place.latitude, place.longitude], { icon, placeId: place.id });
const popupContent = this.createPopupContent(place);
marker.bindPopup(popupContent);
return marker;
}
createPlaceIcon(place) {
const rawEmoji = place.icon || place.tags[0]?.icon || '📍';
const emoji = this.escapeHtml(rawEmoji);
const rawColor = place.color || place.tags[0]?.color || '#4CAF50';
const color = this.sanitizeColor(rawColor);
const iconHtml = `
<div class="place-marker" style="
background-color: ${color};
width: 32px;
height: 32px;
border-radius: 50% 50% 50% 0;
border: 2px solid white;
display: flex;
align-items: center;
justify-content: center;
transform: rotate(-45deg);
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
">
<span style="transform: rotate(45deg); font-size: 16px;">${emoji}</span>
</div>
`;
return L.divIcon({
html: iconHtml,
className: 'place-icon',
iconSize: [32, 32],
iconAnchor: [16, 32],
popupAnchor: [0, -32]
});
}
createPopupContent(place) {
const tags = place.tags.map(tag => {
const safeIcon = this.escapeHtml(tag.icon || '');
const safeName = this.escapeHtml(tag.name || '');
const safeColor = this.sanitizeColor(tag.color);
return `<span class="badge badge-sm" style="background-color: ${safeColor}">
${safeIcon} #${safeName}
</span>`;
}).join(' ');
const safeName = this.escapeHtml(place.name || '');
const safeVisitsCount = place.visits_count ? parseInt(place.visits_count, 10) : 0;
return `
<div class="place-popup" style="min-width: 200px;">
<h3 class="font-bold text-lg mb-2">${safeName}</h3>
${tags ? `<div class="mb-2">${tags}</div>` : ''}
${place.note ? `<p class="text-sm text-gray-600 mb-2 italic">${this.escapeHtml(place.note)}</p>` : ''}
${safeVisitsCount > 0 ? `<p class="text-sm">Visits: ${safeVisitsCount}</p>` : ''}
<div class="mt-2 flex gap-2">
<button class="btn btn-xs btn-primary" data-place-id="${place.id}" data-action="edit-place">
Edit
</button>
<button class="btn btn-xs btn-error" data-place-id="${place.id}" data-action="delete-place">
Delete
</button>
</div>
</div>
`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
sanitizeColor(color) {
// Validate hex color format (#RGB or #RRGGBB)
if (!color || typeof color !== 'string') {
return '#4CAF50'; // Default green
}
const hexColorRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
if (hexColorRegex.test(color)) {
return color;
}
return '#4CAF50'; // Default green for invalid colors
}
setupMapClickHandler() {
this.map.on('click', (e) => {
if (this.creationMode) {
this.handleMapClick(e);
}
});
// Delegate event handling for edit and delete buttons
this.map.on('popupopen', (e) => {
const popup = e.popup;
const popupElement = popup.getElement();
const editBtn = popupElement?.querySelector('[data-action="edit-place"]');
const deleteBtn = popupElement?.querySelector('[data-action="delete-place"]');
if (editBtn) {
editBtn.addEventListener('click', () => {
const placeId = editBtn.dataset.placeId;
this.editPlace(placeId);
popup.remove();
});
}
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
const placeId = deleteBtn.dataset.placeId;
await this.deletePlace(placeId);
popup.remove();
});
}
});
}
async handleMapClick(e) {
const { lat, lng } = e.latlng;
// Remove existing creation marker
if (this.creationMarker) {
this.map.removeLayer(this.creationMarker);
}
// Add temporary marker
this.creationMarker = L.marker([lat, lng], {
icon: this.createPlaceIcon({ icon: '📍', color: '#FF9800' })
}).addTo(this.map);
// Trigger place creation modal
this.triggerPlaceCreation(lat, lng);
}
async triggerPlaceCreation(lat, lng) {
const event = new CustomEvent('place:create', {
detail: { latitude: lat, longitude: lng },
bubbles: true
});
document.dispatchEvent(event);
}
editPlace(placeId) {
const place = this.places.find(p => p.id === parseInt(placeId));
if (!place) {
console.error('Place not found:', placeId);
return;
}
const event = new CustomEvent('place:edit', {
detail: { place },
bubbles: true
});
document.dispatchEvent(event);
}
async deletePlace(placeId) {
if (!confirm('Are you sure you want to delete this place?')) return;
try {
const response = await fetch(`/api/v1/places/${placeId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${this.apiKey}` }
});
if (!response.ok) throw new Error('Failed to delete place');
// Remove marker from main layer
if (this.markers[placeId]) {
this.placesLayer.removeLayer(this.markers[placeId]);
delete this.markers[placeId];
}
// Remove from all layers on the map (including filtered layers)
this.map.eachLayer((layer) => {
if (layer instanceof L.LayerGroup) {
layer.eachLayer((marker) => {
if (marker.options && marker.options.placeId === parseInt(placeId)) {
layer.removeLayer(marker);
}
});
}
});
// Remove from places array
this.places = this.places.filter(p => p.id !== parseInt(placeId));
showFlashMessage('success', 'Place deleted successfully');
} catch (error) {
console.error('Error deleting place:', error);
showFlashMessage('error', 'Failed to delete place');
}
}
enableCreationMode() {
this.creationMode = true;
this.map.getContainer().style.cursor = 'crosshair';
this.showNotification('Click on the map to add a place', 'info');
}
disableCreationMode() {
this.creationMode = false;
this.map.getContainer().style.cursor = '';
if (this.creationMarker) {
this.map.removeLayer(this.creationMarker);
this.creationMarker = null;
}
}
filterByTags(tagIds, untaggedOnly = false) {
this.selectedTags = new Set(tagIds || []);
this.loadPlaces(tagIds && tagIds.length > 0 ? tagIds : null, untaggedOnly);
}
/**
* Create a filtered layer for tree control
* Returns a layer group that will be populated with filtered places
*/
createFilteredLayer(tagIds) {
const filteredLayer = L.layerGroup();
// Store tag IDs for this layer
filteredLayer._tagIds = tagIds;
// Add event listener to load places when layer is added to map
filteredLayer.on('add', () => {
this.loadPlacesIntoLayer(filteredLayer, tagIds);
});
return filteredLayer;
}
/**
* Load places into a specific layer with tag filtering
*/
async loadPlacesIntoLayer(layer, tagIds) {
try {
const url = new URL('/api/v1/places', window.location.origin);
if (Array.isArray(tagIds) && tagIds.length > 0) {
// Specific tags requested
tagIds.forEach(id => url.searchParams.append('tag_ids[]', id));
} else if (Array.isArray(tagIds) && tagIds.length === 0) {
// Empty array means untagged places only
url.searchParams.append('untagged', 'true');
}
const response = await fetch(url, {
headers: { 'Authorization': `Bearer ${this.apiKey}` }
});
const data = await response.json();
// Clear existing markers in this layer
layer.clearLayers();
// Add markers to this layer
data.forEach(place => {
const marker = this.createPlaceMarker(place);
layer.addLayer(marker);
});
} catch (error) {
console.error('Error loading places into layer:', error);
}
}
async refreshPlaces() {
const tagIds = this.selectedTags.size > 0 ? Array.from(this.selectedTags) : null;
await this.loadPlaces(tagIds);
}
ensurePlacesLayerVisible() {
// Check if the main places layer is already on the map
if (this.map.hasLayer(this.placesLayer)) {
return;
}
// Directly add the layer to the map first for immediate visibility
this.map.addLayer(this.placesLayer);
// Then try to sync the checkbox in the layer control if it exists
const layerControl = document.querySelector('.leaflet-control-layers');
if (layerControl) {
setTimeout(() => {
const inputs = layerControl.querySelectorAll('input[type="checkbox"]');
inputs.forEach(input => {
const label = input.closest('label') || input.nextElementSibling;
if (label && label.textContent.trim() === 'Places') {
if (!input.checked) {
// Set a flag to prevent saving during programmatic layer addition
if (window.mapsController) {
window.mapsController.isRestoringLayers = true;
}
input.checked = true;
// Don't dispatch change event since we already added the layer
// Reset the flag after a short delay
setTimeout(() => {
if (window.mapsController) {
window.mapsController.isRestoringLayers = false;
}
}, 50);
}
}
});
}, 100);
}
}
show() {
if (this.placesLayer) {
this.map.addLayer(this.placesLayer);
}
}
hide() {
if (this.placesLayer) {
this.map.removeLayer(this.placesLayer);
}
}
showNotification(message, type = 'info') {
const event = new CustomEvent('notification:show', {
detail: { message, type },
bubbles: true
});
document.dispatchEvent(event);
}
}

View file

@ -0,0 +1,232 @@
import L from 'leaflet';
import { applyThemeToPanel } from './theme_utils';
/**
* Custom Leaflet control for managing Places layer visibility and filtering
*/
export function createPlacesControl(placesManager, tags, userTheme = 'dark') {
return L.Control.extend({
options: {
position: 'topright'
},
onAdd: function(map) {
this.placesManager = placesManager;
this.tags = tags || [];
this.userTheme = userTheme;
this.activeFilters = new Set(); // Track which tags are active
this.showUntagged = false;
this.placesEnabled = false;
// Create main container
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-control-places');
// Prevent map interactions when clicking the control
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
// Create toggle button
this.button = L.DomUtil.create('a', 'leaflet-control-places-button', container);
this.button.href = '#';
this.button.title = 'Places Layer';
this.button.innerHTML = '📍';
this.button.style.fontSize = '20px';
this.button.style.width = '34px';
this.button.style.height = '34px';
this.button.style.lineHeight = '30px';
this.button.style.textAlign = 'center';
this.button.style.textDecoration = 'none';
// Create panel (hidden by default)
this.panel = L.DomUtil.create('div', 'leaflet-control-places-panel', container);
this.panel.style.display = 'none';
this.panel.style.marginTop = '5px';
this.panel.style.minWidth = '200px';
this.panel.style.maxWidth = '280px';
this.panel.style.maxHeight = '400px';
this.panel.style.overflowY = 'auto';
this.panel.style.padding = '10px';
this.panel.style.borderRadius = '4px';
this.panel.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
// Apply theme to panel
applyThemeToPanel(this.panel, this.userTheme);
// Build panel content
this.buildPanelContent();
// Toggle panel on button click
L.DomEvent.on(this.button, 'click', (e) => {
L.DomEvent.preventDefault(e);
this.togglePanel();
});
return container;
},
buildPanelContent: function() {
const html = `
<div style="margin-bottom: 10px; font-weight: bold; font-size: 14px; border-bottom: 1px solid rgba(128,128,128,0.3); padding-bottom: 8px;">
📍 Places Layer
</div>
<!-- All Places Toggle -->
<label style="display: flex; align-items: center; padding: 6px; cursor: pointer; border-radius: 4px; margin-bottom: 4px;"
class="places-control-item"
onmouseover="this.style.backgroundColor='rgba(128,128,128,0.2)'"
onmouseout="this.style.backgroundColor='transparent'">
<input type="checkbox"
data-filter="all"
style="margin-right: 8px; cursor: pointer;"
${this.placesEnabled ? 'checked' : ''}>
<span style="font-weight: bold;">Show All Places</span>
</label>
<!-- Untagged Places Toggle -->
<label style="display: flex; align-items: center; padding: 6px; cursor: pointer; border-radius: 4px; margin-bottom: 8px;"
class="places-control-item"
onmouseover="this.style.backgroundColor='rgba(128,128,128,0.2)'"
onmouseout="this.style.backgroundColor='transparent'">
<input type="checkbox"
data-filter="untagged"
style="margin-right: 8px; cursor: pointer;"
${this.showUntagged ? 'checked' : ''}>
<span>Untagged Places</span>
</label>
${this.tags.length > 0 ? `
<div style="border-top: 1px solid rgba(128,128,128,0.3); padding-top: 8px; margin-top: 8px;">
<div style="font-size: 12px; font-weight: bold; margin-bottom: 6px; opacity: 0.7;">
FILTER BY TAG
</div>
<div style="max-height: 250px; overflow-y: auto; margin-right: -5px; padding-right: 5px;">
${this.tags.map(tag => {
const safeIcon = tag.icon ? this.escapeHtml(tag.icon) : '📍';
const safeColor = this.sanitizeColor(tag.color);
return `
<label style="display: flex; align-items: center; padding: 6px; cursor: pointer; border-radius: 4px; margin-bottom: 2px;"
class="places-control-item"
onmouseover="this.style.backgroundColor='rgba(128,128,128,0.2)'"
onmouseout="this.style.backgroundColor='transparent'">
<input type="checkbox"
data-filter="tag"
data-tag-id="${tag.id}"
style="margin-right: 8px; cursor: pointer;"
${this.activeFilters.has(tag.id) ? 'checked' : ''}>
<span style="font-size: 18px; margin-right: 6px;">${safeIcon}</span>
<span style="flex: 1;">#${this.escapeHtml(tag.name)}</span>
${tag.color ? `<span style="width: 12px; height: 12px; border-radius: 50%; background-color: ${safeColor}; margin-left: 4px;"></span>` : ''}
</label>
`;
}).join('')}
</div>
</div>
` : '<div style="font-size: 12px; opacity: 0.6; padding: 8px; text-align: center;">No tags created yet</div>'}
`;
this.panel.innerHTML = html;
// Add event listeners to checkboxes
const checkboxes = this.panel.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(cb => {
L.DomEvent.on(cb, 'change', (e) => {
this.handleFilterChange(e.target);
});
});
},
handleFilterChange: function(checkbox) {
const filterType = checkbox.dataset.filter;
if (filterType === 'all') {
this.placesEnabled = checkbox.checked;
if (checkbox.checked) {
// Show places layer
this.placesManager.placesLayer.addTo(this.placesManager.map);
this.applyCurrentFilters();
} else {
// Hide places layer
this.placesManager.map.removeLayer(this.placesManager.placesLayer);
// Uncheck all other filters
this.activeFilters.clear();
this.showUntagged = false;
this.buildPanelContent();
}
} else if (filterType === 'untagged') {
this.showUntagged = checkbox.checked;
this.applyCurrentFilters();
} else if (filterType === 'tag') {
const tagId = parseInt(checkbox.dataset.tagId);
if (checkbox.checked) {
this.activeFilters.add(tagId);
} else {
this.activeFilters.delete(tagId);
}
this.applyCurrentFilters();
}
// Update button appearance
this.updateButtonState();
},
applyCurrentFilters: function() {
if (!this.placesEnabled) return;
// Build filter criteria
const tagIds = Array.from(this.activeFilters);
if (this.showUntagged && tagIds.length === 0) {
// Show only untagged places
this.placesManager.filterByTags(null, true);
} else if (tagIds.length > 0) {
// Show places with specific tags
this.placesManager.filterByTags(tagIds, false);
} else {
// Show all places (no filters)
this.placesManager.filterByTags(null, false);
}
},
updateButtonState: function() {
if (this.placesEnabled) {
this.button.style.backgroundColor = '#4CAF50';
this.button.style.color = 'white';
} else {
this.button.style.backgroundColor = '';
this.button.style.color = '';
}
},
togglePanel: function() {
if (this.panel.style.display === 'none') {
this.panel.style.display = 'block';
} else {
this.panel.style.display = 'none';
}
},
escapeHtml: function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
sanitizeColor: function(color) {
// Validate hex color format (#RGB or #RRGGBB)
if (!color || typeof color !== 'string') {
return '#4CAF50'; // Default green
}
const hexColorRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
if (hexColorRegex.test(color)) {
return color;
}
return '#4CAF50'; // Default green for invalid colors
}
});
}

View file

@ -0,0 +1,173 @@
// Privacy Zones Manager
// Handles filtering of map data (points, tracks) based on privacy zones defined by tags
import L from 'leaflet';
import { haversineDistance } from './helpers';
export class PrivacyZoneManager {
constructor(map, apiKey) {
this.map = map;
this.apiKey = apiKey;
this.zones = [];
this.visualLayers = L.layerGroup();
this.showCircles = false;
}
async loadPrivacyZones() {
try {
const response = await fetch('/api/v1/tags/privacy_zones', {
headers: { 'Authorization': `Bearer ${this.apiKey}` }
});
if (!response.ok) {
console.warn('Failed to load privacy zones:', response.status);
return;
}
this.zones = await response.json();
console.log(`[PrivacyZones] Loaded ${this.zones.length} privacy zones`);
} catch (error) {
console.error('Error loading privacy zones:', error);
this.zones = [];
}
}
isPointInPrivacyZone(lat, lng) {
if (!this.zones || this.zones.length === 0) return false;
return this.zones.some(zone =>
zone.places.some(place => {
const distanceKm = haversineDistance(lat, lng, place.latitude, place.longitude);
const distanceMeters = distanceKm * 1000;
return distanceMeters <= zone.radius_meters;
})
);
}
filterPoints(points) {
if (!this.zones || this.zones.length === 0) return points;
// Filter points and ensure polylines break at privacy zone boundaries
// We need to manipulate timestamps to force polyline breaks
const filteredPoints = [];
let lastWasPrivate = false;
let privacyZoneEncountered = false;
for (let i = 0; i < points.length; i++) {
const point = points[i];
const lat = point[0];
const lng = point[1];
const isPrivate = this.isPointInPrivacyZone(lat, lng);
if (!isPrivate) {
// Point is not in privacy zone, include it
const newPoint = [...point]; // Clone the point array
// If we just exited a privacy zone, force a polyline break by adding
// a large time gap that exceeds minutes_between_routes threshold
if (privacyZoneEncountered && filteredPoints.length > 0) {
// Add 2 hours (120 minutes) to timestamp to force a break
// This is larger than default minutes_between_routes (30 min)
const lastPoint = filteredPoints[filteredPoints.length - 1];
if (newPoint[4]) { // If timestamp exists (index 4)
newPoint[4] = lastPoint[4] + (120 * 60); // Add 120 minutes in seconds
}
privacyZoneEncountered = false;
}
filteredPoints.push(newPoint);
lastWasPrivate = false;
} else {
// Point is in privacy zone - skip it
if (!lastWasPrivate) {
privacyZoneEncountered = true;
}
lastWasPrivate = true;
}
}
return filteredPoints;
}
filterTracks(tracks) {
if (!this.zones || this.zones.length === 0) return tracks;
return tracks.map(track => {
const filteredPoints = track.points.filter(point => {
const lat = point[0];
const lng = point[1];
return !this.isPointInPrivacyZone(lat, lng);
});
return {
...track,
points: filteredPoints
};
}).filter(track => track.points.length > 0);
}
showPrivacyCircles() {
this.visualLayers.clearLayers();
if (!this.zones || this.zones.length === 0) return;
this.zones.forEach(zone => {
zone.places.forEach(place => {
const circle = L.circle([place.latitude, place.longitude], {
radius: zone.radius_meters,
color: zone.tag_color || '#ff4444',
fillColor: zone.tag_color || '#ff4444',
fillOpacity: 0.1,
dashArray: '10, 10',
weight: 2,
interactive: false,
className: 'privacy-zone-circle'
});
// Add popup with zone info
circle.bindPopup(`
<div class="privacy-zone-popup">
<strong>${zone.tag_icon || '🔒'} ${zone.tag_name}</strong><br>
<small>${place.name}</small><br>
<small>Privacy radius: ${zone.radius_meters}m</small>
</div>
`);
circle.addTo(this.visualLayers);
});
});
this.visualLayers.addTo(this.map);
this.showCircles = true;
}
hidePrivacyCircles() {
if (this.map.hasLayer(this.visualLayers)) {
this.map.removeLayer(this.visualLayers);
}
this.showCircles = false;
}
togglePrivacyCircles(show = null) {
const shouldShow = show !== null ? show : !this.showCircles;
if (shouldShow) {
this.showPrivacyCircles();
} else {
this.hidePrivacyCircles();
}
}
hasPrivacyZones() {
return this.zones && this.zones.length > 0;
}
getZoneCount() {
return this.zones ? this.zones.length : 0;
}
getTotalPlacesCount() {
if (!this.zones) return 0;
return this.zones.reduce((sum, zone) => sum + zone.places.length, 0);
}
}

View file

@ -1,32 +1,36 @@
/**
* Vector maps configuration for Maps V1 (legacy)
* For Maps V2, use style_manager.js instead
*/
export const mapsConfig = {
"Light": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "light",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"Dark": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "dark",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"White": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "white",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"Grayscale": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "grayscale",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
"Black": {
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
flavor: "black",
maxZoom: 16,
maxZoom: 14,
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, &copy; <a href='https://openstreetmap.org'>OpenStreetMap</a>"
},
};

View file

@ -369,7 +369,7 @@ export class VisitsManager {
const visitsCount = dateGroups[dateStr].count || 0;
return `
<div class="flex justify-between items-center py-1 border-b border-base-300 last:border-0 my-2 hover:bg-accent hover:text-accent-content transition-colors">
<div class="flex justify-between items-center py-1 border-b border-base-300 last:border-0 my-2 hover:bg-accent hover:text-accent-content transition-colors border-radius-md">
<div class="font-medium">${dateStr}</div>
<div class="flex gap-2">
${pointsCount > 0 ? `<div class="badge badge-secondary">${pointsCount} pts</div>` : ''}
@ -379,14 +379,18 @@ export class VisitsManager {
`;
}).join('');
// Create the whole panel
// Create the whole panel with collapsible content
return `
<div class="bg-base-100 rounded-lg p-3 mb-4 shadow-sm">
<h3 class="text-lg font-bold mb-2">Data in Selected Area</h3>
<div class="divide-y divide-base-300">
${dateItems}
<details id="data-section-collapse" class="collapse collapse-arrow bg-base-100 rounded-lg mb-4 shadow-sm">
<summary class="collapse-title text-lg font-bold">
Data in Selected Area
</summary>
<div class="collapse-content">
<div class="divide-y divide-base-300">
${dateItems}
</div>
</div>
</div>
</details>
`;
}
@ -394,37 +398,34 @@ export class VisitsManager {
* Adds a cancel button to the drawer to clear the selection
*/
addSelectionCancelButton() {
console.log('addSelectionCancelButton: Called');
const container = document.getElementById('visits-list');
if (!container) {
console.error('addSelectionCancelButton: visits-list container not found');
return;
}
console.log('addSelectionCancelButton: Container found');
// Remove any existing button container first to avoid duplicates
const existingButtonContainer = document.getElementById('selection-button-container');
if (existingButtonContainer) {
console.log('addSelectionCancelButton: Removing existing button container');
existingButtonContainer.remove();
}
// Create a button container
const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex gap-2 mb-4';
buttonContainer.className = 'flex flex-col gap-2 mb-4';
buttonContainer.id = 'selection-button-container';
// Cancel button
const cancelButton = document.createElement('button');
cancelButton.id = 'cancel-selection-button';
cancelButton.className = 'btn btn-sm btn-warning flex-1';
cancelButton.className = 'btn btn-sm btn-warning w-full';
cancelButton.textContent = 'Cancel Selection';
cancelButton.onclick = () => this.clearSelection();
// Delete all selected points button
const deleteButton = document.createElement('button');
deleteButton.id = 'delete-selection-button';
deleteButton.className = 'btn btn-sm btn-error flex-1';
deleteButton.className = 'btn btn-sm btn-error w-full';
deleteButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>Delete Points';
deleteButton.onclick = () => this.deleteSelectedPoints();
@ -434,9 +435,6 @@ export class VisitsManager {
badge.className = 'badge badge-sm ml-1';
badge.textContent = this.selectedPoints.length;
deleteButton.appendChild(badge);
console.log(`addSelectionCancelButton: Added badge with ${this.selectedPoints.length} points`);
} else {
console.warn('addSelectionCancelButton: No selected points, selectedPoints =', this.selectedPoints);
}
buttonContainer.appendChild(cancelButton);
@ -444,15 +442,6 @@ export class VisitsManager {
// Insert at the beginning of the container
container.insertBefore(buttonContainer, container.firstChild);
console.log('addSelectionCancelButton: Buttons inserted into DOM');
// Verify buttons are in DOM
setTimeout(() => {
const verifyDelete = document.getElementById('delete-selection-button');
const verifyCancel = document.getElementById('cancel-selection-button');
console.log('addSelectionCancelButton: Verification - Delete button exists:', !!verifyDelete);
console.log('addSelectionCancelButton: Verification - Cancel button exists:', !!verifyCancel);
}, 100);
}
/**
@ -592,12 +581,21 @@ export class VisitsManager {
const controlsLayer = {
Points: this.mapsController.markersLayer || L.layerGroup(),
Routes: this.mapsController.polylinesLayer || L.layerGroup(),
Tracks: this.mapsController.tracksLayer || L.layerGroup(),
Heatmap: this.mapsController.heatmapLayer || L.layerGroup(),
"Fog of War": this.mapsController.fogOverlay,
"Scratch map": this.mapsController.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.mapsController.areasLayer || L.layerGroup(),
Photos: this.mapsController.photoMarkers || L.layerGroup()
Photos: this.mapsController.photoMarkers || L.layerGroup(),
"Suggested Visits": this.getVisitCirclesLayer(),
"Confirmed Visits": this.getConfirmedVisitCirclesLayer()
};
// Include Family Members layer if available
if (window.familyMembersController?.familyMarkersLayer) {
controlsLayer['Family Members'] = window.familyMembersController.familyMarkersLayer;
}
this.mapsController.layerControl = L.control.layers(
this.mapsController.baseMaps(),
controlsLayer
@ -626,11 +624,6 @@ export class VisitsManager {
drawerButton.innerHTML = this.drawerOpen ? '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-close-icon lucide-panel-right-close"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m8 9 3 3-3 3"/></svg>' : '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panel-right-open-icon lucide-panel-right-open"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>';
}
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel, .drawer-button, #selection-tool-button');
controls.forEach(control => {
control.classList.toggle('controls-shifted');
});
// Update the drawer content if it's being opened - but don't fetch visits automatically
// Only show the "no data" message if there's no selection active
if (this.drawerOpen && !this.isSelectionActive) {
@ -654,16 +647,18 @@ export class VisitsManager {
createDrawer() {
const drawer = document.createElement('div');
drawer.id = 'visits-drawer';
drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-39 overflow-y-auto leaflet-drawer';
drawer.className = 'bg-base-100 shadow-lg z-39 overflow-y-auto leaflet-drawer';
// Add styles to make the drawer scrollable
drawer.style.overflowY = 'auto';
drawer.style.maxHeight = '100vh';
drawer.innerHTML = `
<div class="p-3 drawer">
<h2 class="text-xl font-bold mb-4 text-accent-content">Recent Visits</h2>
<div id="visits-list" class="space-y-2">
<div class="p-3 my-2 drawer flex flex-col items-center relative">
<button id="close-visits-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" title="Close panel">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-x-icon lucide-circle-x"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>
</button>
<h2 class="text-xl font-bold mb-4 text-accent-content w-full text-center">Recent Visits</h2>
<div id="visits-list" class="space-y-2 w-full">
<p class="text-gray-500">Loading visits...</p>
</div>
</div>
@ -675,6 +670,15 @@ export class VisitsManager {
L.DomEvent.disableClickPropagation(drawer);
this.map.getContainer().appendChild(drawer);
// Add close button event listener
const closeButton = drawer.querySelector('#close-visits-drawer');
if (closeButton) {
closeButton.addEventListener('click', () => {
this.toggleDrawer();
});
}
return drawer;
}
@ -833,6 +837,10 @@ export class VisitsManager {
return;
}
// Save the current state of collapsible sections before updating
const dataSectionOpen = document.querySelector('#data-section-collapse')?.open || false;
const visitsSectionOpen = document.querySelector('#visits-section-collapse')?.open || false;
// Update the drawer title if selection is active
if (this.isSelectionActive && this.selectionRect) {
const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0;
@ -896,7 +904,7 @@ export class VisitsManager {
const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : '';
return `
<div class="w-full p-3 m-2 rounded-lg hover:bg-base-300 transition-colors visit-item relative ${bgClass}"
<div class="w-full p-3 mt-2 rounded-lg hover:bg-base-300 transition-colors visit-item relative ${bgClass}"
style="${visitStyle}"
data-lat="${visit.place?.latitude || ''}"
data-lng="${visit.place?.longitude || ''}"
@ -924,8 +932,31 @@ export class VisitsManager {
`;
}).join('');
// Wrap visits in a collapsible section
const visitsSection = visits && visits.length > 0 ? `
<details id="visits-section-collapse" class="collapse collapse-arrow bg-base-100 rounded-lg mb-4 shadow-sm">
<summary class="collapse-title text-lg font-bold">
Visits (${visits.filter(v => v.status !== 'declined').length})
</summary>
<div class="collapse-content">
${visitsHtml}
</div>
</details>
` : '';
// Combine date summary and visits HTML
container.innerHTML = dateGroupsHtml + visitsHtml;
container.innerHTML = dateGroupsHtml + visitsSection;
// Restore the state of collapsible sections
const dataSection = document.querySelector('#data-section-collapse');
const visitsSection2 = document.querySelector('#visits-section-collapse');
if (dataSection && dataSectionOpen) {
dataSection.open = true;
}
if (visitsSection2 && visitsSectionOpen) {
visitsSection2.open = true;
}
// Add the circles layer to the map
this.visitCircles.addTo(this.map);

View file

@ -0,0 +1,118 @@
import consumer from '../../channels/consumer'
/**
* Create map channel subscription for maps_maplibre
* Wraps the existing FamilyLocationsChannel and other channels for real-time updates
* @param {Object} options - { received, connected, disconnected, enableLiveMode }
* @returns {Object} Subscriptions object with multiple channels
*/
export function createMapChannel(options = {}) {
const { enableLiveMode = false, ...callbacks } = options
const subscriptions = {
family: null,
points: null,
notifications: null
}
console.log('[MapChannel] Creating channels with enableLiveMode:', enableLiveMode)
// Defensive check - consumer might not be available
if (!consumer) {
console.warn('[MapChannel] ActionCable consumer not available')
return {
subscriptions,
unsubscribeAll() {}
}
}
// Subscribe to family locations if family feature is enabled
try {
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]')
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {}
if (features.family) {
subscriptions.family = consumer.subscriptions.create('FamilyLocationsChannel', {
connected() {
console.log('FamilyLocationsChannel connected')
callbacks.connected?.('family')
},
disconnected() {
console.log('FamilyLocationsChannel disconnected')
callbacks.disconnected?.('family')
},
received(data) {
console.log('FamilyLocationsChannel received:', data)
callbacks.received?.({
type: 'family_location',
member: data
})
}
})
}
} catch (error) {
console.warn('[MapChannel] Failed to subscribe to family channel:', error)
}
// Subscribe to points channel for real-time point updates (only if live mode is enabled)
if (enableLiveMode) {
try {
subscriptions.points = consumer.subscriptions.create('PointsChannel', {
connected() {
console.log('PointsChannel connected')
callbacks.connected?.('points')
},
disconnected() {
console.log('PointsChannel disconnected')
callbacks.disconnected?.('points')
},
received(data) {
console.log('PointsChannel received:', data)
callbacks.received?.({
type: 'new_point',
point: data
})
}
})
} catch (error) {
console.warn('[MapChannel] Failed to subscribe to points channel:', error)
}
} else {
console.log('[MapChannel] Live mode disabled, not subscribing to PointsChannel')
}
// Subscribe to notifications channel
try {
subscriptions.notifications = consumer.subscriptions.create('NotificationsChannel', {
connected() {
console.log('NotificationsChannel connected')
callbacks.connected?.('notifications')
},
disconnected() {
console.log('NotificationsChannel disconnected')
callbacks.disconnected?.('notifications')
},
received(data) {
console.log('NotificationsChannel received:', data)
callbacks.received?.({
type: 'notification',
notification: data
})
}
})
} catch (error) {
console.warn('[MapChannel] Failed to subscribe to notifications channel:', error)
}
return {
subscriptions,
unsubscribeAll() {
Object.values(subscriptions).forEach(sub => sub?.unsubscribe())
}
}
}

View file

@ -0,0 +1,100 @@
/**
* Factory for creating photo popups
*/
export class PhotoPopupFactory {
/**
* Create popup for a photo
* @param {Object} properties - Photo properties
* @returns {string} HTML for popup
*/
static createPhotoPopup(properties) {
const {
id,
thumbnail_url,
taken_at,
filename,
city,
state,
country,
type,
source
} = properties
const takenDate = taken_at ? new Date(taken_at).toLocaleString() : 'Unknown'
const location = [city, state, country].filter(Boolean).join(', ') || 'Unknown location'
const mediaType = type === 'VIDEO' ? '🎥 Video' : '📷 Photo'
return `
<div class="photo-popup">
<div class="photo-preview">
<img src="${thumbnail_url}"
alt="${filename}"
loading="lazy">
</div>
<div class="photo-info">
<div class="filename">${filename}</div>
<div class="timestamp">Taken: ${takenDate}</div>
<div class="location">Location: ${location}</div>
<div class="source">Source: ${source}</div>
<div class="media-type">${mediaType}</div>
</div>
</div>
<style>
.photo-popup {
font-family: system-ui, -apple-system, sans-serif;
max-width: 300px;
}
.photo-preview {
width: 100%;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
background: #f3f4f6;
}
.photo-preview img {
width: 100%;
height: auto;
max-height: 300px;
object-fit: cover;
display: block;
}
.photo-info {
font-size: 13px;
}
.photo-info > div {
margin-bottom: 6px;
}
.photo-info .filename {
font-weight: 600;
color: #111827;
}
.photo-info .timestamp {
color: #6b7280;
font-size: 12px;
}
.photo-info .location {
color: #6b7280;
font-size: 12px;
}
.photo-info .source {
color: #9ca3af;
font-size: 11px;
}
.photo-info .media-type {
font-size: 14px;
margin-top: 8px;
}
</style>
`
}
}

View file

@ -0,0 +1,114 @@
import { formatTimestamp } from '../utils/geojson_transformers'
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
/**
* Factory for creating map popups
*/
export class PopupFactory {
/**
* Create popup for a point
* @param {Object} properties - Point properties
* @returns {string} HTML for popup
*/
static createPointPopup(properties) {
const { id, timestamp, altitude, battery, accuracy, velocity } = properties
// Get theme colors
const theme = getCurrentTheme()
const colors = getThemeColors(theme)
return `
<div class="point-popup" style="color: ${colors.textPrimary};">
<div class="popup-header" style="border-bottom: 1px solid ${colors.border};">
<strong>Point #${id}</strong>
</div>
<div class="popup-body">
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Time:</span>
<span class="value" style="color: ${colors.textPrimary};">${formatTimestamp(timestamp)}</span>
</div>
${altitude ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Altitude:</span>
<span class="value" style="color: ${colors.textPrimary};">${Math.round(altitude)}m</span>
</div>
` : ''}
${battery ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Battery:</span>
<span class="value" style="color: ${colors.textPrimary};">${battery}%</span>
</div>
` : ''}
${accuracy ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Accuracy:</span>
<span class="value" style="color: ${colors.textPrimary};">${Math.round(accuracy)}m</span>
</div>
` : ''}
${velocity ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Speed:</span>
<span class="value" style="color: ${colors.textPrimary};">${Math.round(velocity * 3.6)} km/h</span>
</div>
` : ''}
</div>
</div>
`
}
/**
* Create popup for a place
* @param {Object} properties - Place properties
* @returns {string} HTML for popup
*/
static createPlacePopup(properties) {
const { id, name, latitude, longitude, note, tags } = properties
// Get theme colors
const theme = getCurrentTheme()
const colors = getThemeColors(theme)
// Parse tags if they're stringified
let parsedTags = tags
if (typeof tags === 'string') {
try {
parsedTags = JSON.parse(tags)
} catch (e) {
parsedTags = []
}
}
// Format tags as badges
const tagsHtml = parsedTags && Array.isArray(parsedTags) && parsedTags.length > 0
? parsedTags.map(tag => `
<span class="badge badge-sm" style="background-color: ${tag.color}; color: white;">
${tag.icon} #${tag.name}
</span>
`).join(' ')
: `<span class="badge badge-sm badge-outline" style="border-color: ${colors.border}; color: ${colors.textMuted};">Untagged</span>`
return `
<div class="place-popup" style="color: ${colors.textPrimary};">
<div class="popup-header" style="border-bottom: 1px solid ${colors.border};">
<strong>${name || `Place #${id}`}</strong>
</div>
<div class="popup-body">
${note ? `
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Note:</span>
<span class="value" style="color: ${colors.textPrimary};">${note}</span>
</div>
` : ''}
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Tags:</span>
<div class="value">${tagsHtml}</div>
</div>
<div class="popup-row">
<span class="label" style="color: ${colors.textMuted};">Coordinates:</span>
<span class="value" style="color: ${colors.textPrimary};">${latitude.toFixed(5)}, ${longitude.toFixed(5)}</span>
</div>
</div>
</div>
`
}
}

View file

@ -0,0 +1,183 @@
/**
* Toast notification system
* Displays temporary notifications in the top-right corner
*/
export class Toast {
static container = null
/**
* Initialize toast container
*/
static init() {
if (this.container) return
this.container = document.createElement('div')
this.container.className = 'toast-container'
this.container.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
`
document.body.appendChild(this.container)
// Add CSS animations
this.addStyles()
}
/**
* Add CSS animations for toasts
*/
static addStyles() {
if (document.getElementById('toast-styles')) return
const style = document.createElement('style')
style.id = 'toast-styles'
style.textContent = `
@keyframes toast-slide-in {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes toast-slide-out {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast {
pointer-events: auto;
animation: toast-slide-in 0.3s ease-out;
}
.toast.removing {
animation: toast-slide-out 0.3s ease-out;
}
`
document.head.appendChild(style)
}
/**
* Show toast notification
* @param {string} message - Message to display
* @param {string} type - Toast type: 'success', 'error', 'info', 'warning'
* @param {number} duration - Duration in milliseconds (default 3000)
*/
static show(message, type = 'info', duration = 3000) {
this.init()
const toast = document.createElement('div')
toast.className = `toast toast-${type}`
toast.textContent = message
toast.style.cssText = `
padding: 12px 20px;
background: ${this.getBackgroundColor(type)};
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 14px;
font-weight: 500;
max-width: 300px;
line-height: 1.4;
`
this.container.appendChild(toast)
// Auto dismiss after duration
if (duration > 0) {
setTimeout(() => {
this.dismiss(toast)
}, duration)
}
return toast
}
/**
* Dismiss a toast
* @param {HTMLElement} toast - Toast element to dismiss
*/
static dismiss(toast) {
toast.classList.add('removing')
setTimeout(() => {
toast.remove()
}, 300)
}
/**
* Get background color for toast type
* @param {string} type - Toast type
* @returns {string} CSS color
*/
static getBackgroundColor(type) {
const colors = {
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6'
}
return colors[type] || colors.info
}
/**
* Show success toast
* @param {string} message
* @param {number} duration
*/
static success(message, duration = 3000) {
return this.show(message, 'success', duration)
}
/**
* Show error toast
* @param {string} message
* @param {number} duration
*/
static error(message, duration = 4000) {
return this.show(message, 'error', duration)
}
/**
* Show warning toast
* @param {string} message
* @param {number} duration
*/
static warning(message, duration = 3500) {
return this.show(message, 'warning', duration)
}
/**
* Show info toast
* @param {string} message
* @param {number} duration
*/
static info(message, duration = 3000) {
return this.show(message, 'info', duration)
}
/**
* Clear all toasts
*/
static clearAll() {
if (!this.container) return
const toasts = this.container.querySelectorAll('.toast')
toasts.forEach(toast => this.dismiss(toast))
}
}

View file

@ -0,0 +1,156 @@
/**
* Visit card component for rendering individual visit cards in the side panel
*/
export class VisitCard {
/**
* Create HTML for a visit card
* @param {Object} visit - Visit object with id, name, status, started_at, ended_at, duration, place
* @param {Object} options - { isSelected, onSelect, onConfirm, onDecline, onHover }
* @returns {string} HTML string
*/
static create(visit, options = {}) {
const { isSelected = false, onSelect, onConfirm, onDecline, onHover } = options
const isSuggested = visit.status === 'suggested'
const isConfirmed = visit.status === 'confirmed'
const isDeclined = visit.status === 'declined'
// Format date and time
const startDate = new Date(visit.started_at)
const endDate = new Date(visit.ended_at)
const dateStr = startDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
const timeRange = `${startDate.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})} - ${endDate.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
})}`
// Format duration (duration is in minutes from the backend)
const hours = Math.floor(visit.duration / 60)
const minutes = visit.duration % 60
const durationStr = hours > 0
? `${hours}h ${minutes}m`
: `${minutes}m`
// Border style based on status
const borderClass = isSuggested ? 'border-dashed' : ''
const bgClass = isDeclined ? 'bg-base-200 opacity-60' : 'bg-base-100'
const selectedClass = isSelected ? 'ring-2 ring-primary' : ''
return `
<div class="visit-card card ${bgClass} ${borderClass} ${selectedClass} border-2 border-base-content/20 mb-2 hover:shadow-md transition-all relative"
data-visit-id="${visit.id}"
data-visit-status="${visit.status}"
onmouseenter="this.querySelector('.visit-checkbox').classList.remove('hidden')"
onmouseleave="if(!this.querySelector('.visit-checkbox input').checked) this.querySelector('.visit-checkbox').classList.add('hidden')">
<!-- Checkbox (hidden by default, shown on hover) -->
<div class="visit-checkbox absolute top-3 right-3 z-10 ${isSelected ? '' : 'hidden'}">
<input type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
${isSelected ? 'checked' : ''}
data-visit-select="${visit.id}"
onclick="event.stopPropagation()">
</div>
<div class="card-body p-3">
<!-- Visit Name -->
<h3 class="card-title text-sm font-semibold mb-2">
${visit.name || visit.place?.name || 'Unnamed Visit'}
</h3>
<!-- Date and Time -->
<div class="text-xs text-base-content/70 space-y-1">
<div class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="truncate">${dateStr}</span>
</div>
<div class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="truncate">${timeRange}</span>
</div>
<div class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<span class="truncate">${durationStr}</span>
</div>
</div>
<!-- Action buttons for suggested visits -->
${isSuggested ? `
<div class="card-actions justify-end mt-3 gap-1.5">
<button class="btn btn-xs btn-outline btn-error" data-visit-decline="${visit.id}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Decline
</button>
<button class="btn btn-xs btn-primary" data-visit-confirm="${visit.id}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Confirm
</button>
</div>
` : ''}
<!-- Status badge for confirmed/declined visits -->
${isConfirmed || isDeclined ? `
<div class="mt-2">
<span class="badge badge-xs ${isConfirmed ? 'badge-success' : 'badge-error'}">
${visit.status}
</span>
</div>
` : ''}
</div>
</div>
`
}
/**
* Create bulk action buttons HTML
* @param {number} selectedCount - Number of selected visits
* @returns {string} HTML string
*/
static createBulkActions(selectedCount) {
if (selectedCount < 2) return ''
return `
<div class="bulk-actions-panel sticky bottom-0 bg-base-100 border-t border-base-300 p-4 mt-4 space-y-2">
<div class="text-sm font-medium mb-3">
${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected
</div>
<div class="grid grid-cols-3 gap-2">
<button class="btn btn-sm btn-outline" data-bulk-merge>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Merge
</button>
<button class="btn btn-sm btn-primary" data-bulk-confirm>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Confirm
</button>
<button class="btn btn-sm btn-outline btn-error" data-bulk-decline>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Decline
</button>
</div>
</div>
`
}
}

View file

@ -0,0 +1,138 @@
import { formatTimestamp } from '../utils/geojson_transformers'
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
/**
* Factory for creating visit popups
*/
export class VisitPopupFactory {
/**
* Create popup for a visit
* @param {Object} properties - Visit properties
* @returns {string} HTML for popup
*/
static createVisitPopup(properties) {
const { id, name, status, started_at, ended_at, duration, place_name } = properties
const startTime = formatTimestamp(started_at)
const endTime = formatTimestamp(ended_at)
const durationHours = Math.round(duration / 3600)
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(duration / 60)}m`
// Get theme colors
const theme = getCurrentTheme()
const colors = getThemeColors(theme)
return `
<div class="visit-popup">
<div class="popup-header">
<strong>${name || place_name || 'Unknown Place'}</strong>
<span class="visit-badge ${status}">${status}</span>
</div>
<div class="popup-body">
<div class="popup-row">
<span class="label">Arrived:</span>
<span class="value">${startTime}</span>
</div>
<div class="popup-row">
<span class="label">Left:</span>
<span class="value">${endTime}</span>
</div>
<div class="popup-row">
<span class="label">Duration:</span>
<span class="value">${durationDisplay}</span>
</div>
</div>
<div class="popup-footer">
<a href="/visits/${id}" class="view-details-btn">View Details </a>
</div>
</div>
<style>
.visit-popup {
font-family: system-ui, -apple-system, sans-serif;
min-width: 280px;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid ${colors.border};
gap: 12px;
}
.popup-header strong {
font-size: 15px;
flex: 1;
}
.visit-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
flex-shrink: 0;
}
.visit-badge.suggested {
background: ${colors.badgeSuggested.bg};
color: ${colors.badgeSuggested.text};
}
.visit-badge.confirmed {
background: ${colors.badgeConfirmed.bg};
color: ${colors.badgeConfirmed.text};
}
.popup-body {
font-size: 13px;
margin-bottom: 16px;
}
.popup-row {
margin-bottom: 10px;
}
.popup-row .label {
color: ${colors.textMuted};
display: block;
margin-bottom: 4px;
font-size: 12px;
}
.popup-row .value {
font-weight: 500;
color: ${colors.textPrimary};
display: block;
}
.popup-footer {
padding-top: 12px;
border-top: 1px solid ${colors.border};
}
.view-details-btn {
display: block;
text-align: center;
padding: 10px 16px;
background: ${colors.accent};
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
transition: background 0.2s;
}
.view-details-btn:hover {
background: ${colors.accentHover};
}
</style>
`
}
}

Some files were not shown because too many files have changed in this diff Show more