Merge branch 'dev' into feature/omniauth

This commit is contained in:
Eugene Burmakin 2025-11-14 18:22:36 +01:00
commit fde478e2a4
111 changed files with 5646 additions and 5222 deletions

View file

@ -1 +1 @@
0.34.0
0.35.1

View file

@ -96,7 +96,7 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/Dockerfile.dev
file: ./docker/Dockerfile
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

1
.gitignore vendored
View file

@ -84,3 +84,4 @@ node_modules/
/playwright-report/
/blob-report/
/playwright/.cache/
/e2e/temp/

View file

@ -4,7 +4,22 @@ 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]
## Unreleased
## 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.
## Fixed
- The map settings panel is now scrollable
---
## 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
@ -16,6 +31,63 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- [ ] Disable GitHub and Google authentication for self-hosted Dawarich
- [ ] In selfhosted env, no registrations are allowed, we need to account OIDC into that
# [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
- Selection tool on the map now can select points that user can delete in bulk. #433
## Fixed
- 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 contains a link to share with the invitee.
## Changed
- Removed useless system tests and cover map functionality with Playwright e2e tests instead.
- 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.
# [0.34.2] - 2025-10-31
## Fixed
- Fixed a bug in UTM trackable concern. #1909
# [0.34.1] - 2025-10-30
## Fixed
- Broken Stats page for users with no reverse geocoding enabled. #1877
## Changed
- Date navigation on the map page is no longer shown as floating panel. It is now part of the top navigation bar to prevent overlapping with other map controls. #1894 #1881
## Added
- [Dawarich Cloud] Added support for UTM parameters during user registration. UTM parameters will be stored with the user record for marketing analytics purposes.
# [0.34.0] - 2025-10-10
## The Family release

View file

@ -5,7 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby File.read('.ruby-version').strip
gem 'activerecord-postgis-adapter'
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
@ -33,12 +33,12 @@ gem 'pg'
gem 'prometheus_exporter'
gem 'puma'
gem 'pundit', '>= 2.5.1'
gem 'rails', '~> 8.0', '>= 8.0.3'
gem 'rails', '~> 8.0'
gem 'rails_icons'
gem 'redis'
gem 'rexml'
gem 'rgeo'
gem 'rgeo-activerecord'
gem 'rgeo-activerecord', '~> 8.0.0'
gem 'rgeo-geojson'
gem 'rqrcode', '~> 3.0'
gem 'rswag-api'
@ -52,7 +52,6 @@ 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]
@ -84,4 +83,5 @@ 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

@ -109,11 +109,10 @@ GEM
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.4.1)
bigdecimal (3.2.3)
bindata (2.5.1)
bigdecimal (3.3.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.0.2)
brakeman (7.1.0)
racc
builder (3.3.0)
bundler-audit (0.9.2)
@ -142,12 +141,12 @@ GEM
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.4)
data_migrate (11.3.0)
data_migrate (11.3.1)
activerecord (>= 6.1)
railties (>= 6.1)
database_consistency (2.0.6)
activerecord (>= 3.2)
date (3.4.1)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
@ -164,9 +163,7 @@ GEM
dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.3)
email_validator (2.2.4)
activemodel
erb (5.0.2)
erb (5.1.3)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@ -272,7 +269,7 @@ GEM
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.5)
minitest (5.26.0)
msgpack (1.7.3)
multi_json (1.15.0)
multi_xml (0.7.1)
@ -362,7 +359,7 @@ GEM
pg (1.6.2-arm64-darwin)
pg (1.6.2-x86_64-darwin)
pg (1.6.2-x86_64-linux)
pp (0.6.2)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.5.1)
@ -386,18 +383,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.2)
rack-oauth2 (2.2.1)
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 (3.2.3)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@ -439,10 +425,11 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.0)
rdoc (6.14.2)
rake (13.3.1)
rdoc (6.15.0)
erb
psych (>= 4.0.0)
tsort
redis (5.4.0)
redis-client (>= 0.22.0)
redis-client (0.24.0)
@ -484,17 +471,17 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.3)
rswag-api (2.16.0)
activesupport (>= 5.2, < 8.1)
railties (>= 5.2, < 8.1)
rswag-specs (2.16.0)
activesupport (>= 5.2, < 8.1)
json-schema (>= 2.2, < 6.0)
railties (>= 5.2, < 8.1)
rswag-api (2.17.0)
activesupport (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rswag-specs (2.17.0)
activesupport (>= 5.2, < 8.2)
json-schema (>= 2.2, < 7.0)
railties (>= 5.2, < 8.2)
rspec-core (>= 2.14)
rswag-ui (2.16.0)
actionpack (>= 5.2, < 8.1)
railties (>= 5.2, < 8.1)
rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.81.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@ -524,10 +511,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (5.28.0)
railties (>= 5.0)
sentry-ruby (~> 5.28.0)
sentry-ruby (5.28.0)
sentry-rails (6.0.0)
railties (>= 5.2.0)
sentry-ruby (~> 6.0.0)
sentry-ruby (6.0.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shoulda-matchers (6.5.0)
@ -567,15 +554,10 @@ GEM
stringio (3.1.7)
strong_migrations (2.5.1)
activerecord (>= 7.1)
super_diff (0.16.0)
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)
@ -586,7 +568,7 @@ GEM
tailwindcss-ruby (3.4.17-x86_64-darwin)
tailwindcss-ruby (3.4.17-x86_64-linux)
thor (1.4.0)
timeout (0.4.3)
timeout (0.4.4)
tsort (0.2.0)
turbo-rails (2.0.17)
actionpack (>= 7.1.0)
@ -597,7 +579,7 @@ GEM
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.0.3)
uri (1.0.4)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
@ -632,7 +614,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
activerecord-postgis-adapter
activerecord-postgis-adapter (~> 11.0)
aws-sdk-core (~> 3.215.1)
aws-sdk-kms (~> 1.96.0)
aws-sdk-s3 (~> 1.177.0)
@ -671,12 +653,12 @@ DEPENDENCIES
pry-rails
puma
pundit (>= 2.5.1)
rails (~> 8.0, >= 8.0.3)
rails (~> 8.0)
rails_icons
redis
rexml
rgeo
rgeo-activerecord
rgeo-activerecord (~> 8.0.0)
rgeo-geojson
rqrcode (~> 3.0)
rspec-rails (>= 8.0.1)

View file

@ -79,6 +79,8 @@ Simply install one of the supported apps on your device and configure it to send
⏹️ **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

@ -76,33 +76,46 @@
/* 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 +140,5 @@
/* Cancel Selection Button */
#cancel-selection-button {
margin-bottom: 1rem;
width: 100%;
}

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::PointsController < ApiController
before_action :authenticate_active_api_user!, only: %i[create update destroy]
before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy]
before_action :validate_points_limit, only: %i[create]
def index
@ -45,6 +45,16 @@ class Api::V1::PointsController < ApiController
render json: { message: 'Point deleted successfully' }
end
def bulk_destroy
point_ids = bulk_destroy_params[:point_ids]
render json: { error: 'No points selected' }, status: :unprocessable_entity and return if point_ids.blank?
deleted_count = current_api_user.points.where(id: point_ids).destroy_all.count
render json: { message: 'Points were successfully destroyed', count: deleted_count }, status: :ok
end
private
def point_params
@ -55,6 +65,10 @@ class Api::V1::PointsController < ApiController
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
end
def bulk_destroy_params
params.permit(point_ids: [])
end
def point_serializer
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
end

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
module UtmTrackable
extend ActiveSupport::Concern
UTM_PARAMS = %w[utm_source utm_medium utm_campaign utm_term utm_content].freeze
def store_utm_params
UTM_PARAMS.each do |param|
session[param] = params[param] if params[param].present?
end
end
def assign_utm_params(record)
utm_data = extract_utm_data_from_session
return unless utm_data.any?
record.update_columns(utm_data)
clear_utm_session
end
private
def extract_utm_data_from_session
UTM_PARAMS.each_with_object({}) do |param, hash|
hash[param] = session[param] if session[param].present?
end
end
def clear_utm_session
UTM_PARAMS.each { |param| session.delete(param) }
end
end

View file

@ -77,6 +77,8 @@ class FamiliesController < ApplicationController
end
def update_location_sharing
authorize @family, :update_location_sharing?
result = Families::UpdateLocationSharing.new(
user: current_user,
enabled: params[:enabled],

View file

@ -1,8 +1,11 @@
# frozen_string_literal: true
class Users::RegistrationsController < Devise::RegistrationsController
include UtmTrackable
before_action :set_invitation, only: %i[new create]
before_action :check_registration_allowed, only: %i[new create]
before_action :store_utm_params, only: %i[new], unless: -> { DawarichSettings.self_hosted? }
def new
build_resource({})
@ -16,8 +19,9 @@ class Users::RegistrationsController < Devise::RegistrationsController
def create
super do |resource|
if resource.persisted? && @invitation
accept_invitation_for_user(resource)
if resource.persisted?
assign_utm_params(resource)
accept_invitation_for_user(resource) if @invitation
end
end
end
@ -47,7 +51,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
end
def set_invitation
return unless invitation_token.present?
return if invitation_token.blank?
@invitation = Family::Invitation.find_by(token: invitation_token)
end
@ -65,8 +69,8 @@ class Users::RegistrationsController < Devise::RegistrationsController
def invitation_token
@invitation_token ||= params[:invitation_token] ||
params.dig(:user, :invitation_token) ||
session[:invitation_token]
params.dig(:user, :invitation_token) ||
session[:invitation_token]
end
def accept_invitation_for_user(user)
@ -80,11 +84,13 @@ class Users::RegistrationsController < Devise::RegistrationsController
if service.call
flash[:notice] = "Welcome to #{@invitation.family.name}! You're now part of the family."
else
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
flash[:alert] =
"Account created successfully, but there was an issue accepting the invitation: #{service.error_message}"
end
rescue StandardError => e
Rails.logger.error "Error accepting invitation during registration: #{e.message}"
flash[:alert] = "Account created successfully, but there was an issue accepting the invitation. Please try accepting it again."
flash[:alert] =
'Account created successfully, but there was an issue accepting the invitation. Please try accepting it again.'
end
def sign_up_params

View file

@ -3,13 +3,14 @@
module CountryFlagHelper
def country_flag(country_name)
country_code = country_to_code(country_name)
return "" unless country_code
return '' unless country_code
country_code = 'TW' if country_code == 'CN-TW'
# Convert country code to regional indicator symbols (flag emoji)
country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
country_code.upcase.each_char.map { |c| (c.ord + 127_397).chr(Encoding::UTF_8) }.join
end
private
def country_to_code(country_name)

View file

@ -148,6 +148,10 @@ export default class extends Controller {
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
} else {
console.warn('No currentPopup reference found');
// Fallback: try to close any open popup
this.map.closePopup();
}
}
@ -263,7 +267,10 @@ export default class extends Controller {
}
if (cancelButton) {
cancelButton.addEventListener('click', () => {
cancelButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.exitAddVisitMode(this.addVisitButton);
});
}
@ -346,8 +353,6 @@ export default class extends Controller {
}
addCreatedVisitToMap(visitData, latitude, longitude) {
console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData });
const mapsController = document.querySelector('[data-controller*="maps"]');
if (!mapsController) {
console.log('Could not find maps controller element');
@ -357,6 +362,7 @@ export default class extends Controller {
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
if (!stimulusController || !stimulusController.visitsManager) {
console.log('Could not find maps controller or visits manager');
return;
}
@ -376,16 +382,10 @@ export default class extends Controller {
// Add the circle to the confirmed visits layer
visitsManager.confirmedVisitCircles.addLayer(circle);
console.log('✅ Added newly created confirmed visit circle to layer');
console.log('Confirmed visits layer info:', {
layerCount: visitsManager.confirmedVisitCircles.getLayers().length,
isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles)
});
// Make sure the layer is visible on the map
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
this.map.addLayer(visitsManager.confirmedVisitCircles);
console.log('✅ Added confirmed visits layer to map');
}
// Check if the layer control has the confirmed visits layer enabled
@ -411,9 +411,7 @@ export default class extends Controller {
inputs.forEach(input => {
const label = input.nextElementSibling;
if (label && label.textContent.trim().includes('Confirmed Visits')) {
console.log('Found Confirmed Visits checkbox, current state:', input.checked);
if (!input.checked) {
console.log('Enabling Confirmed Visits layer via checkbox');
input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
}

View file

@ -29,7 +29,7 @@ export default class extends Controller {
if (this.isUploading) {
// If still uploading, prevent submission
event.preventDefault()
console.log("Form submission prevented during upload")
return
}
@ -41,7 +41,7 @@ export default class extends Controller {
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
if (signedIds.length === 0) {
event.preventDefault()
console.log("No files uploaded yet")
alert("Please select and upload files first")
} else {
console.log(`Submitting form with ${signedIds.length} uploaded files`)
@ -78,7 +78,6 @@ export default class extends Controller {
}
}
console.log(`Uploading ${files.length} files`)
this.isUploading = true
// Disable submit button during upload
@ -124,8 +123,6 @@ export default class extends Controller {
// Add the progress wrapper AFTER the file input field but BEFORE the submit button
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
console.log("Progress bar created and inserted before submit button")
let uploadCount = 0
const totalFiles = files.length
@ -137,17 +134,13 @@ export default class extends Controller {
});
Array.from(files).forEach(file => {
console.log(`Starting upload for ${file.name}`)
const upload = new DirectUpload(file, this.urlValue, this)
upload.create((error, blob) => {
uploadCount++
if (error) {
console.error("Error uploading file:", error)
// Show error to user using flash
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
} else {
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
// Create a hidden field with the correct name
const hiddenField = document.createElement("input")
@ -155,8 +148,6 @@ export default class extends Controller {
hiddenField.setAttribute("name", "import[files][]")
hiddenField.setAttribute("value", blob.signed_id)
this.element.appendChild(hiddenField)
console.log("Added hidden field with signed ID:", blob.signed_id)
}
// Enable submit button when all uploads are complete
@ -186,8 +177,6 @@ export default class extends Controller {
}
}
this.isUploading = false
console.log("All uploads completed")
console.log(`Ready to submit with ${successfulUploads} files`)
}
})
})

View file

@ -208,7 +208,7 @@ export default class extends BaseController {
this.addInfoToggleButton();
// Initialize the visits manager
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme);
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme, this);
// Expose visits manager globally for location search integration
window.visitsManager = this.visitsManager;
@ -712,6 +712,9 @@ export default class extends BaseController {
if (this.map.hasLayer(this.fogOverlay)) {
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
}
// Show success message
showFlashMessage('notice', 'Point deleted successfully');
})
.catch(error => {
console.error('There was a problem with the delete request:', error);
@ -952,100 +955,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");

View file

@ -122,9 +122,8 @@ export default class extends BaseController {
});
});
// Add markers and route
// Add route (no markers on trip forms)
if (this.coordinates?.length > 0) {
this.addMarkers()
this.addPolyline()
this.fitMapToBounds()
}
@ -246,9 +245,8 @@ export default class extends BaseController {
this.polylinesLayer.clearLayers()
this.photoMarkers.clearLayers()
// Add new markers and route if coordinates exist
// Add only polyline (no markers) when coordinates exist
if (this.coordinates?.length > 0) {
this.addMarkers()
this.addPolyline()
this.fitMapToBounds()
}

View file

@ -1,14 +1,16 @@
import L from "leaflet";
import { showFlashMessage } from "./helpers";
import { createPolylinesLayer } from "./polylines";
/**
* Manages visits functionality including displaying, fetching, and interacting with visits
*/
export class VisitsManager {
constructor(map, apiKey, userTheme = 'dark') {
constructor(map, apiKey, userTheme = 'dark', mapsController = null) {
this.map = map;
this.apiKey = apiKey;
this.userTheme = userTheme;
this.mapsController = mapsController;
// Create custom panes for different visit types
// Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700
@ -218,15 +220,20 @@ export class VisitsManager {
// Set selection as active to ensure date summary is displayed
this.isSelectionActive = true;
this.displayVisits(visits);
// Make sure the drawer is open
// Make sure the drawer is open FIRST, before displaying visits
if (!this.drawerOpen) {
this.toggleDrawer();
}
// Add cancel selection button to the drawer
this.addSelectionCancelButton();
// Now display visits in the drawer
this.displayVisits(visits);
// Add cancel selection button to the drawer AFTER displayVisits
// This needs to be after because displayVisits sets innerHTML which would wipe out the buttons
// Use setTimeout to ensure DOM has fully updated
setTimeout(() => {
this.addSelectionCancelButton();
}, 0);
} catch (error) {
console.error('Error fetching visits in selection:', error);
@ -362,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>` : ''}
@ -372,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>
`;
}
@ -388,18 +399,207 @@ export class VisitsManager {
*/
addSelectionCancelButton() {
const container = document.getElementById('visits-list');
if (!container) return;
if (!container) {
console.error('addSelectionCancelButton: visits-list container not found');
return;
}
// Add cancel button at the top of the drawer if it doesn't exist
if (!document.getElementById('cancel-selection-button')) {
const cancelButton = document.createElement('button');
cancelButton.id = 'cancel-selection-button';
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full';
cancelButton.textContent = 'Cancel Area Selection';
cancelButton.onclick = () => this.clearSelection();
// Remove any existing button container first to avoid duplicates
const existingButtonContainer = document.getElementById('selection-button-container');
if (existingButtonContainer) {
existingButtonContainer.remove();
}
// Insert at the beginning of the container
container.insertBefore(cancelButton, container.firstChild);
// Create a button container
const buttonContainer = document.createElement('div');
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 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 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();
// Add count badge if we have selected points
if (this.selectedPoints && this.selectedPoints.length > 0) {
const badge = document.createElement('span');
badge.className = 'badge badge-sm ml-1';
badge.textContent = this.selectedPoints.length;
deleteButton.appendChild(badge);
}
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(deleteButton);
// Insert at the beginning of the container
container.insertBefore(buttonContainer, container.firstChild);
}
/**
* Deletes all points in the current selection
*/
async deleteSelectedPoints() {
if (!this.selectedPoints || this.selectedPoints.length === 0) {
showFlashMessage('warning', 'No points selected');
return;
}
const pointCount = this.selectedPoints.length;
const confirmed = confirm(
`⚠️ WARNING: This will permanently delete ${pointCount} point${pointCount > 1 ? 's' : ''} from your location history.\n\n` +
`This action cannot be undone!\n\n` +
`Are you sure you want to continue?`
);
if (!confirmed) return;
try {
// Get point IDs from the selected points
// Debug: log the structure of selected points
console.log('Selected points sample:', this.selectedPoints[0]);
// Points format: [lat, lng, ?, ?, timestamp, ?, id, country, ?]
// ID is at index 6 based on the marker array structure
const pointIds = this.selectedPoints
.map(point => point[6]) // ID is at index 6
.filter(id => id != null && id !== '');
console.log('Point IDs to delete:', pointIds);
if (pointIds.length === 0) {
showFlashMessage('error', 'No valid point IDs found');
return;
}
// Call the bulk delete API
const response = await fetch('/api/v1/points/bulk_destroy', {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({ point_ids: pointIds })
});
if (!response.ok) {
const errorText = await response.text();
console.error('Response error:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log('Delete result:', result);
// Check if any points were actually deleted
if (result.count === 0) {
showFlashMessage('warning', 'No points were deleted. They may have already been removed.');
this.clearSelection();
return;
}
// Show success message
showFlashMessage('notice', `Successfully deleted ${result.count} point${result.count > 1 ? 's' : ''}`);
// Remove deleted points from the map
pointIds.forEach(id => {
this.mapsController.removeMarker(id);
});
// Update the polylines layer
this.updatePolylinesAfterDeletion();
// Update heatmap with remaining markers
if (this.mapsController.heatmapLayer) {
this.mapsController.heatmapLayer.setLatLngs(
this.mapsController.markers.map(marker => [marker[0], marker[1], 0.2])
);
}
// Update fog if enabled
if (this.mapsController.fogOverlay && this.mapsController.map.hasLayer(this.mapsController.fogOverlay)) {
this.mapsController.updateFog(
this.mapsController.markers,
this.mapsController.clearFogRadius,
this.mapsController.fogLineThreshold
);
}
// Clear selection
this.clearSelection();
} catch (error) {
console.error('Error deleting points:', error);
showFlashMessage('error', 'Failed to delete points. Please try again.');
}
}
/**
* Updates polylines layer after deletion (similar to single point deletion)
*/
updatePolylinesAfterDeletion() {
let wasPolyLayerVisible = false;
// Check if polylines layer was visible
if (this.mapsController.polylinesLayer) {
if (this.mapsController.map.hasLayer(this.mapsController.polylinesLayer)) {
wasPolyLayerVisible = true;
}
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
}
// Create new polylines layer with updated markers
this.mapsController.polylinesLayer = createPolylinesLayer(
this.mapsController.markers,
this.mapsController.map,
this.mapsController.timezone,
this.mapsController.routeOpacity,
this.mapsController.userSettings,
this.mapsController.distanceUnit
);
// Re-add to map if it was visible, otherwise ensure it's removed
if (wasPolyLayerVisible) {
this.mapsController.polylinesLayer.addTo(this.mapsController.map);
} else {
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
}
// Update layer control
if (this.mapsController.layerControl) {
this.mapsController.map.removeControl(this.mapsController.layerControl);
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(),
"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
).addTo(this.mapsController.map);
}
}
@ -424,13 +624,9 @@ 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
if (this.drawerOpen) {
// Only show the "no data" message if there's no selection active
if (this.drawerOpen && !this.isSelectionActive) {
const container = document.getElementById('visits-list');
if (container) {
container.innerHTML = `
@ -451,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>
@ -472,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;
}
@ -630,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;
@ -693,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 || ''}"
@ -721,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

@ -13,9 +13,10 @@ class Family::Invitations::CleanupJob < ApplicationJob
Rails.logger.info "Updated #{expired_count} expired family invitations"
cleanup_threshold = 30.days.ago
deleted_count = Family::Invitation.where(status: [:expired, :cancelled])
.where('updated_at < ?', cleanup_threshold)
.delete_all
deleted_count =
Family::Invitation.where(status: %i[expired cancelled])
.where('updated_at < ?', cleanup_threshold)
.delete_all
Rails.logger.info "Deleted #{deleted_count} old family invitations"

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Family::Invitations::SendingJob < ApplicationJob
queue_as :families
def perform(invitation_id)
invitation = Family::Invitation.find_by(id: invitation_id)
return unless invitation&.pending?
FamilyMailer.invitation(invitation).deliver_now
end
end

View file

@ -38,8 +38,8 @@ class Tracks::DailyGenerationJob < ApplicationJob
Tracks::ParallelGeneratorJob.perform_later(
user.id,
start_at: start_timestamp,
end_at: Time.current.to_i,
start_at: Time.zone.at(start_timestamp),
end_at: Time.current,
mode: 'daily'
)
end

View file

@ -11,6 +11,8 @@ class Family < ApplicationRecord
MAX_MEMBERS = 5
def can_add_members?
return true if DawarichSettings.self_hosted?
(member_count + pending_invitations_count) < MAX_MEMBERS
end
@ -32,6 +34,8 @@ class Family < ApplicationRecord
end
def full?
return false if DawarichSettings.self_hosted?
(member_count + pending_invitations_count) >= MAX_MEMBERS
end

View file

@ -22,7 +22,7 @@ class Import < ApplicationRecord
enum :source, {
google_semantic_history: 0, owntracks: 1, google_records: 2,
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7,
user_data_archive: 8
user_data_archive: 8, kml: 9
}, allow_nil: true
def process!

View file

@ -1,22 +0,0 @@
# frozen_string_literal: true
class FamilyInvitationPolicy < ApplicationPolicy
def show?
# Public endpoint for invitation acceptance - no authentication required
true
end
def create?
user.family == record.family && user.family_owner?
end
def accept?
# Users can accept invitations sent to their email
user.email == record.email
end
def destroy?
# Only family owners can cancel invitations
user.family == record.family && user.family_owner?
end
end

View file

@ -1,23 +0,0 @@
# frozen_string_literal: true
class FamilyMembershipPolicy < ApplicationPolicy
def show?
user.family == record.family
end
def update?
# Users can update their own settings
return true if user == record.user
# Family owners can update any member's settings
user.family == record.family && user.family_owner?
end
def destroy?
# Users can remove themselves (handled by family leave logic)
return true if user == record.user
# Family owners can remove other members
user.family == record.family && user.family_owner?
end
end

View file

@ -34,6 +34,10 @@ class FamilyPolicy < ApplicationPolicy
user.family == record && user.family_owner?
end
def update_location_sharing?
user.family == record && user.family_owner?
end
private
def family_owner_with_members?

View file

@ -1,5 +1,17 @@
# frozen_string_literal: true
# Simple wrapper class that acts like GPX::GPXFile but preserves enhanced XML
class EnhancedGpxFile < GPX::GPXFile
def initialize(name, xml_string)
super(name: name)
@enhanced_xml = xml_string
end
def to_s
@enhanced_xml
end
end
class Points::GpxSerializer
def initialize(points, name)
@points = points
@ -7,30 +19,92 @@ class Points::GpxSerializer
end
def call
gpx_file = GPX::GPXFile.new(name: "dawarich_#{name}")
track = GPX::Track.new(name: "dawarich_#{name}")
gpx_file = create_base_gpx_file
add_track_points_to_gpx(gpx_file)
xml_string = enhance_gpx_with_speed_and_course(gpx_file.to_s)
gpx_file.tracks << track
track_segment = GPX::Segment.new
track.segments << track_segment
points.each do |point|
track_segment.points << GPX::TrackPoint.new(
lat: point.lat,
lon: point.lon,
elevation: point.altitude.to_f,
time: point.recorded_at
)
end
GPX::GPXFile.new(
name: "dawarich_#{name}",
gpx_data: gpx_file.to_s.sub('<gpx', '<gpx xmlns="http://www.topografix.com/GPX/1/1"')
)
EnhancedGpxFile.new("dawarich_#{name}", xml_string)
end
private
attr_reader :points, :name
def create_base_gpx_file
gpx_file = GPX::GPXFile.new(name: "dawarich_#{name}")
track = GPX::Track.new(name: "dawarich_#{name}")
gpx_file.tracks << track
track_segment = GPX::Segment.new
track.segments << track_segment
gpx_file
end
def add_track_points_to_gpx(gpx_file)
track_segment = gpx_file.tracks.first.segments.first
points.each do |point|
track_point = create_track_point(point)
track_segment.points << track_point
end
end
def create_track_point(point)
track_point_attrs = build_track_point_attributes(point)
GPX::TrackPoint.new(**track_point_attrs)
end
def build_track_point_attributes(point)
{
lat: point.lat,
lon: point.lon,
elevation: point.altitude.to_f,
time: point.recorded_at
}
end
def enhance_gpx_with_speed_and_course(gpx_xml)
xml_string = add_gpx_namespace(gpx_xml)
enhance_trackpoints_with_speed_and_course(xml_string)
end
def add_gpx_namespace(gpx_xml)
gpx_xml.sub('<gpx', '<gpx xmlns="http://www.topografix.com/GPX/1/1"')
end
def enhance_trackpoints_with_speed_and_course(xml_string)
trkpt_count = 0
xml_string.gsub(/(<trkpt[^>]*>.*?<\/trkpt>)/m) do |trkpt_xml|
point = points[trkpt_count]
trkpt_count += 1
enhance_single_trackpoint(trkpt_xml, point)
end
end
def enhance_single_trackpoint(trkpt_xml, point)
enhanced_trkpt = add_speed_to_trackpoint(trkpt_xml, point)
add_course_to_trackpoint(enhanced_trkpt, point)
end
def add_speed_to_trackpoint(trkpt_xml, point)
return trkpt_xml unless should_include_speed?(point)
trkpt_xml.sub(/(<ele>[^<]*<\/ele>)/, "\\1\n <speed>#{point.velocity.to_f}</speed>")
end
def add_course_to_trackpoint(trkpt_xml, point)
return trkpt_xml unless should_include_course?(point)
extensions_xml = "\n <extensions>\n <course>#{point.course.to_f}</course>\n </extensions>"
trkpt_xml.sub(/\n <\/trkpt>/, "#{extensions_xml}\n </trkpt>")
end
def should_include_speed?(point)
point.velocity.present? && point.velocity.to_f > 0
end
def should_include_course?(point)
point.course.present?
end
end

View file

@ -19,8 +19,8 @@ module Families
return false unless invite_sendable?
ActiveRecord::Base.transaction do
create_invitation
send_invitation_email
invitation = create_invitation
send_invitation_email(invitation)
send_notification
end
@ -80,16 +80,18 @@ module Families
)
end
def send_invitation_email
# Send email in background with retry logic
FamilyMailer.invitation(@invitation).deliver_later(
queue: :mailer,
retry: 3,
wait: 30.seconds
)
def send_invitation_email(invitation)
Family::Invitations::SendingJob.perform_later(invitation.id)
end
def send_notification
message =
if DawarichSettings.self_hosted?
"Family invitation sent to #{email} if SMTP is configured properly. If you're not using SMTP, copy the invitation link from the family page and share it manually."
else
"Family invitation sent to #{email}"
end
Notification.create!(
user: invited_by,
kind: :info,

View file

@ -58,6 +58,7 @@ class Imports::Create
when 'google_records' then GoogleMaps::RecordsStorageImporter
when 'owntracks' then OwnTracks::Importer
when 'gpx' then Gpx::TrackImporter
when 'kml' then Kml::Importer
when 'geojson' then Geojson::Importer
when 'immich_api', 'photoprism_api' then Photos::Importer
else

View file

@ -71,6 +71,7 @@ class Imports::SourceDetector
def detect_source
return :gpx if gpx_file?
return :kml if kml_file?
return :owntracks if owntracks_file?
json_data = parse_json
@ -116,6 +117,22 @@ class Imports::SourceDetector
) && content_to_check.include?('<gpx')
end
def kml_file?
return false unless filename&.downcase&.end_with?('.kml', '.kmz')
content_to_check =
if file_path && File.exist?(file_path)
# Read first 1KB for KML detection
File.open(file_path, 'rb') { |f| f.read(1024) }
else
file_content
end
(
content_to_check.strip.start_with?('<?xml') ||
content_to_check.strip.start_with?('<kml')
) && content_to_check.include?('<kml')
end
def owntracks_file?
return false unless filename

View file

@ -0,0 +1,234 @@
# frozen_string_literal: true
require 'rexml/document'
class Kml::Importer
include Imports::Broadcaster
include Imports::FileLoader
attr_reader :import, :user_id, :file_path
def initialize(import, user_id, file_path = nil)
@import = import
@user_id = user_id
@file_path = file_path
end
def call
file_content = load_file_content
doc = REXML::Document.new(file_content)
points_data = []
# Process all Placemarks which can contain various geometry types
REXML::XPath.each(doc, '//Placemark') do |placemark|
points_data.concat(parse_placemark(placemark))
end
# Process gx:Track elements (Google Earth extensions for GPS tracks)
REXML::XPath.each(doc, '//gx:Track') do |track|
points_data.concat(parse_gx_track(track))
end
points_data.compact!
return if points_data.empty?
# Process in batches to avoid memory issues with large files
points_data.each_slice(1000) do |batch|
bulk_insert_points(batch)
end
end
private
def parse_placemark(placemark)
points = []
timestamp = extract_timestamp(placemark)
# Handle Point geometry
point_node = REXML::XPath.first(placemark, './/Point/coordinates')
if point_node
coords = parse_coordinates(point_node.text)
points << build_point(coords.first, timestamp, placemark) if coords.any?
end
# Handle LineString geometry (tracks/routes)
linestring_node = REXML::XPath.first(placemark, './/LineString/coordinates')
if linestring_node
coords = parse_coordinates(linestring_node.text)
coords.each do |coord|
points << build_point(coord, timestamp, placemark)
end
end
# Handle MultiGeometry (can contain multiple Points, LineStrings, etc.)
REXML::XPath.each(placemark, './/MultiGeometry//coordinates') do |coords_node|
coords = parse_coordinates(coords_node.text)
coords.each do |coord|
points << build_point(coord, timestamp, placemark)
end
end
points.compact
end
def parse_gx_track(track)
# Google Earth Track extension with coordinated when/coord pairs
points = []
timestamps = []
REXML::XPath.each(track, './/when') do |when_node|
timestamps << when_node.text.strip
end
coordinates = []
REXML::XPath.each(track, './/gx:coord') do |coord_node|
coordinates << coord_node.text.strip
end
# Match timestamps with coordinates
[timestamps.size, coordinates.size].min.times do |i|
begin
time = Time.parse(timestamps[i]).to_i
coord_parts = coordinates[i].split(/\s+/)
next if coord_parts.size < 2
lng, lat, alt = coord_parts.map(&:to_f)
points << {
lonlat: "POINT(#{lng} #{lat})",
altitude: alt&.to_i || 0,
timestamp: time,
import_id: import.id,
velocity: 0.0,
raw_data: { source: 'gx_track', index: i },
user_id: user_id,
created_at: Time.current,
updated_at: Time.current
}
rescue StandardError => e
Rails.logger.warn("Failed to parse gx:Track point at index #{i}: #{e.message}")
next
end
end
points
end
def parse_coordinates(coord_text)
# KML coordinates format: "longitude,latitude[,altitude] ..."
# Multiple coordinates separated by whitespace
return [] if coord_text.blank?
coord_text.strip.split(/\s+/).map do |coord_str|
parts = coord_str.split(',')
next if parts.size < 2
{
lng: parts[0].to_f,
lat: parts[1].to_f,
alt: parts[2]&.to_f || 0.0
}
end.compact
end
def extract_timestamp(placemark)
# Try TimeStamp first
timestamp_node = REXML::XPath.first(placemark, './/TimeStamp/when')
return Time.parse(timestamp_node.text).to_i if timestamp_node
# Try TimeSpan begin
timespan_begin = REXML::XPath.first(placemark, './/TimeSpan/begin')
return Time.parse(timespan_begin.text).to_i if timespan_begin
# Try TimeSpan end as fallback
timespan_end = REXML::XPath.first(placemark, './/TimeSpan/end')
return Time.parse(timespan_end.text).to_i if timespan_end
# Default to import creation time if no timestamp found
import.created_at.to_i
rescue StandardError => e
Rails.logger.warn("Failed to parse timestamp: #{e.message}")
import.created_at.to_i
end
def build_point(coord, timestamp, placemark)
return if coord[:lat].blank? || coord[:lng].blank?
{
lonlat: "POINT(#{coord[:lng]} #{coord[:lat]})",
altitude: coord[:alt].to_i,
timestamp: timestamp,
import_id: import.id,
velocity: extract_velocity(placemark),
raw_data: extract_extended_data(placemark),
user_id: user_id,
created_at: Time.current,
updated_at: Time.current
}
end
def extract_velocity(placemark)
# Try to extract speed from ExtendedData
speed_node = REXML::XPath.first(placemark, ".//Data[@name='speed']/value") ||
REXML::XPath.first(placemark, ".//Data[@name='Speed']/value") ||
REXML::XPath.first(placemark, ".//Data[@name='velocity']/value")
return speed_node.text.to_f.round(1) if speed_node
0.0
rescue StandardError
0.0
end
def extract_extended_data(placemark)
data = {}
# Extract name if present
name_node = REXML::XPath.first(placemark, './/name')
data['name'] = name_node.text.strip if name_node
# Extract description if present
desc_node = REXML::XPath.first(placemark, './/description')
data['description'] = desc_node.text.strip if desc_node
# Extract all ExtendedData/Data elements
REXML::XPath.each(placemark, './/ExtendedData/Data') do |data_node|
name = data_node.attributes['name']
value_node = REXML::XPath.first(data_node, './value')
data[name] = value_node.text if name && value_node
end
data
rescue StandardError => e
Rails.logger.warn("Failed to extract extended data: #{e.message}")
{}
end
def bulk_insert_points(batch)
unique_batch = batch.uniq { |record| [record[:lonlat], record[:timestamp], record[:user_id]] }
# rubocop:disable Rails/SkipsModelValidations
Point.upsert_all(
unique_batch,
unique_by: %i[lonlat timestamp user_id],
returning: false,
on_duplicate: :skip
)
# rubocop:enable Rails/SkipsModelValidations
broadcast_import_progress(import, unique_batch.size)
rescue StandardError => e
create_notification("Failed to process KML file: #{e.message}")
end
def create_notification(message)
Notification.create!(
user_id: user_id,
title: 'KML Import Error',
content: message,
kind: :error
)
end
end

View file

@ -17,6 +17,8 @@
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', method: :put, data: { turbo_method: :put, turbo: false }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<div class="form-control">
<%= f.label :email, class: 'label' do %>
<span class="label-text">Email</span>

View file

@ -16,12 +16,23 @@
</span>
</div>
<% else %>
<h1 class="text-5xl font-bold text-base-content">Register now!</h1>
<p class="py-6 text-base-content opacity-70">and take control over your location data.</p>
<h1 class="text-5xl font-bold text-base-content">Almost there!</h1>
<% end %>
<p class="py-6 text-base-content opacity-70">
Only a few steps left until you get control over your location data!
</p>
<ol>
<li class="mb-2">1. Create your account</li>
<li class="mb-2">2. Configure your mobile app</li>
<li class="mb-2">3. Start tracking your location data securely</li>
<li class="mb-2">4. ...</li>
<li class="mb-2">5. You're beautiful!</li>
</ol>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<% if @invitation %>
<%= f.hidden_field :invitation_token, value: params[:invitation_token] %>
<% end %>
@ -32,7 +43,7 @@
<% end %>
<%= f.email_field :email, autofocus: true, autocomplete: "email",
readonly: @invitation.present?,
class: "input input-bordered #{@invitation ? 'input-disabled' : ''}" %>
class: "input input-bordered w-full #{@invitation ? 'input-disabled' : ''}" %>
</div>
<div class="form-control">
@ -42,7 +53,7 @@
<% if @minimum_password_length %>
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered' %>
<%= f.password_field :password, autocomplete: "new-password", class: 'input input-bordered w-full' %>
</div>
<div class="form-control">
@ -52,7 +63,7 @@
<% if @minimum_password_length %>
<em class="text-base-content opacity-60 text-sm">(<%= @minimum_password_length %> characters minimum)</em>
<% end %><br />
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered' %>
<%= f.password_field :password_confirmation, autocomplete: "new-password", class: 'input input-bordered w-full' %>
</div>
<% if !DawarichSettings.self_hosted? %>

View file

@ -20,6 +20,8 @@
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<%= render "devise/shared/error_messages", resource: resource %>
<% if @invitation %>
<%= hidden_field_tag :invitation_token, params[:invitation_token] %>
<% end %>

View file

@ -1,15 +1,20 @@
<% if resource.errors.any? %>
<div id="error_explanation" data-turbo-cache="false">
<h2>
<%= I18n.t("errors.messages.not_saved",
count: resource.errors.count,
resource: resource.class.model_name.human.downcase)
%>
</h2>
<ul>
<% resource.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<div id="error_explanation" class="alert alert-error mb-4" data-turbo-cache="false">
<%= icon 'circle-x' %>
<div class="font-bold mb-4 flex items-center gap-2">
<div>
<h3 class="font-bold">
<%= I18n.t("errors.messages.not_saved",
count: resource.errors.count,
resource: resource.class.model_name.human.downcase)
%>
</h3>
<ul class="text-sm mt-1">
<% resource.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>

View file

@ -88,7 +88,7 @@
<% if policy(@family).destroy? %>
<%= link_to family_path,
method: :delete,
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_method: :delete },
class: "btn btn-outline btn-error" do %>
<%= icon 'trash-2', class: "inline-block w-4" %>
Delete Family

View file

@ -26,7 +26,7 @@
<% if !current_user.family_owner? && current_user.family_membership %>
<%= link_to family_member_path(current_user.family_membership),
method: :delete,
data: { turbo_confirm: 'Are you sure you want to leave this family?' },
data: { turbo_confirm: 'Are you sure you want to leave this family?', turbo_method: :delete },
class: "btn btn-outline btm-sm btn-warning" do %>
Leave Family
<% end %>
@ -35,7 +35,7 @@
<% if policy(@family).destroy? %>
<%= link_to family_path,
method: :delete,
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.' },
data: { turbo_confirm: 'Are you sure you want to delete this family? This action cannot be undone.', turbo_method: :delete },
class: "btn btn-outline btm-sm btn-error" do %>
<%= icon 'trash-2', class: "inline-block w-4" %>
Delete
@ -175,38 +175,46 @@
<% if @pending_invitations.any? %>
<div class="space-y-3 mb-4">
<% @pending_invitations.each do |invitation| %>
<div class="flex items-center justify-between p-3 bg-base-100 rounded-lg">
<div class="flex-grow">
<div class="font-medium text-base-content"><%= invitation.email %></div>
<div class="text-sm text-base-content opacity-60">
<%= t('families.show.invited_on', default: 'Invited') %>
<%= invitation.created_at.strftime('%b %d, %Y') %>
</div>
<div class="text-xs text-base-content opacity-50">
<%= t('families.show.expires_on', default: 'Expires') %>
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
</div>
<div class="mt-2">
<button data-controller="clipboard"
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
data-action="click->clipboard#copy"
class="btn btn-outline btn-info btn-xs"
title="Copy invitation link">
<%= icon 'copy', class: "inline-block w-3" %>
Copy Invitation Link
</button>
<div class="p-3 bg-base-100 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex-grow">
<div class="font-medium text-base-content"><%= invitation.email %></div>
<div class="text-sm text-base-content opacity-60">
<%= t('families.show.invited_on', default: 'Invited') %>
<%= invitation.created_at.strftime('%b %d, %Y') %>
</div>
<div class="text-xs text-base-content opacity-50">
<%= t('families.show.expires_on', default: 'Expires') %>
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
</div>
</div>
<% if policy(@family).manage_invitations? %>
<div class="ml-3">
<%= button_to family_invitation_path(invitation.token),
method: :delete,
form: { data: { turbo_confirm: 'Are you sure you want to cancel this invitation?', turbo_method: :delete } },
class: "btn btn-outline btn-warning btn-sm" do %>
Cancel
<% end %>
</div>
<% end %>
</div>
<div class="flex items-center gap-2 mt-3">
<input type="text"
readonly
value="<%= public_invitation_url(invitation.token) %>"
class="input input-bordered input-sm flex-grow"
onclick="this.select();"
/>
<button data-controller="clipboard"
data-clipboard-text-value="<%= public_invitation_url(invitation.token) %>"
data-action="click->clipboard#copy"
class="btn btn-outline btn-info btn-sm ml-auto"
title="Copy invitation link">
<%= icon 'copy', class: "inline-block w-3" %>
Copy Invitation Link
</button>
</div>
<% if policy(@family).manage_invitations? %>
<div class="ml-3">
<%= link_to family_invitation_path(invitation.token),
method: :delete,
data: { turbo_confirm: 'Are you sure you want to cancel this invitation?' },
class: "btn btn-outline btn-warning btn-sm" do %>
Cancel
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>

View file

@ -7,6 +7,7 @@
<li><strong>✅ GPX:</strong> Track files (.gpx)</li>
<li><strong>✅ GeoJSON:</strong> Feature collections (.json)</li>
<li><strong>✅ OwnTracks:</strong> Recorder files (.rec)</li>
<li><strong>✅ KML:</strong> KML files (.kml)</li>
</ul>
<div class="text-xs text-gray-500 mt-2">
File format is automatically detected during upload.

View file

@ -38,7 +38,7 @@
</div>
<!-- Full Screen Map Container -->
<div class='absolute top-16 left-0 right-0 w-full z-20' style='height: calc(100vh - 4rem);'>
<div class='absolute top-16 left-0 right-0 bottom-0 w-full z-20 flex flex-col'>
<%= yield %>
</div>

View file

@ -184,7 +184,7 @@
Here you can set a custom color scale for speed colored routes. It uses color stops at specified km/h values and creates a gradient from it. The default value is <code>0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300</code>
</p>
<p class="py-4">
You can also use the 'Edit Scale' button to edit it using an UI.
You can also use the 'Edit Colors' button to edit it using an UI.
</p>
</div>
<label class="modal-backdrop" for="speed_color_scale_info">Close</label>

View file

@ -1,10 +1,9 @@
<% content_for :title, 'Map' %>
<!-- Floating Date Navigation Controls -->
<div class="fixed top-20 left-0 right-0 flex justify-center" style="z-index: 9999; margin-left: 80px; margin-right: 80px;">
<div style="width: 1500px; max-width: 100%;" data-controller="map-controls">
<!-- Date Navigation Controls - Native Page Element -->
<div class="w-full px-4 py-3 bg-base-100" data-controller="map-controls">
<!-- Mobile: Compact Toggle Button -->
<div class="lg:hidden justify-center flex">
<div class="lg:hidden flex justify-center">
<button
type="button"
data-action="click->map-controls#toggle"
@ -19,7 +18,7 @@
<!-- Expandable Panel (hidden on mobile by default, always visible on desktop) -->
<div
data-map-controls-target="panel"
class="hidden lg:!block bg-base-100 bg-opacity-95 rounded-lg shadow-lg p-4 mt-2 lg:mt-0 scale-80">
class="hidden lg:!block bg-base-100 rounded-lg shadow-lg p-4 mt-2 lg:mt-0">
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
<div class="w-full lg:w-1/12">
@ -71,29 +70,30 @@
</div>
<% end %>
</div>
</div>
</div>
<!-- Full Screen Map -->
<div
id='map'
class="absolute inset-0 w-full h-full z-0"
data-controller="maps points add-visit family-members"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
data-user_theme="<%= current_user&.theme || 'dark' %>"
data-coordinates='<%= @coordinates.to_json.html_safe %>'
data-tracks='<%= @tracks.to_json.html_safe %>'
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"
data-timezone="<%= Rails.configuration.time_zone %>"
data-features='<%= @features.to_json.html_safe %>'
data-family-members-features-value='<%= @features.to_json.html_safe %>'
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
<div data-maps-target="container" class="w-full h-full">
<div id="fog" class="fog"></div>
<!-- Map Container - Fills remaining space -->
<div class="w-full h-full">
<div
id='map'
class="w-full h-full"
data-controller="maps points add-visit family-members"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
data-user_theme="<%= current_user&.theme || 'dark' %>"
data-coordinates='<%= @coordinates.to_json.html_safe %>'
data-tracks='<%= @tracks.to_json.html_safe %>'
data-distance="<%= @distance %>"
data-points_number="<%= @points_number %>"
data-timezone="<%= Rails.configuration.time_zone %>"
data-features='<%= @features.to_json.html_safe %>'
data-family-members-features-value='<%= @features.to_json.html_safe %>'
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
<div data-maps-target="container" class="w-full h-full">
<div id="fog" class="fog"></div>
</div>
</div>
</div>

View file

@ -156,8 +156,7 @@
<li>
<details>
<summary>
<span class="hidden xl:inline"><%= current_user.email %></span>
<span class="inline xl:hidden"><%= icon 'user' %></span>
<span class="inline"><%= icon 'user' %></span>
<% if onboarding_modal_showable?(current_user) %>
<span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %>

View file

@ -18,6 +18,8 @@
<% if DawarichSettings.reverse_geocoding_enabled? %>
<%= render 'stats/reverse_geocoding_stats' %>
<% else %>
</div>
<% end %>
<div class='text-xs text-gray-500 text-center mt-5'>

View file

@ -1,6 +1,7 @@
default: &default
adapter: redis
url: <%= "#{ENV.fetch("REDIS_URL")}/#{ENV.fetch('RAILS_WS_DB', 2)}" %>
url: <%= "#{ENV.fetch("REDIS_URL", "redis://localhost:6379")}" %>
db: <%= ENV.fetch('RAILS_WS_DB', 2) %>
development:
<<: *default

View file

@ -26,7 +26,10 @@ Rails.application.configure do
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
config.cache_store = :redis_cache_store, {
url: ENV['REDIS_URL'],
db: ENV.fetch('RAILS_CACHE_DB', 0)
}
if Rails.root.join('tmp/caching-dev.txt').exist?
config.action_controller.perform_caching = true
@ -86,7 +89,7 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip)
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] || hosts.first, port: ENV.fetch('PORT', 3000) }
@ -99,5 +102,5 @@ Rails.application.configure do
config.lograge.enabled = true
config.lograge.formatter = Lograge::Formatters::Json.new
config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3
config.active_storage.service = ENV.fetch('STORAGE_BACKEND', :local)
end

View file

@ -43,7 +43,7 @@ Rails.application.configure do
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
# Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = ENV['SELF_HOSTED'] == 'true' ? :local : :s3
config.active_storage.service = ENV.fetch('STORAGE_BACKEND', :local)
config.silence_healthcheck_path = '/api/v1/health'
@ -73,7 +73,10 @@ Rails.application.configure do
config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info')
# Use a different cache store in production.
config.cache_store = :redis_cache_store, { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}" }
config.cache_store = :redis_cache_store, {
url: ENV['REDIS_URL'],
db: ENV.fetch('RAILS_CACHE_DB', 0)
}
# Use a real queuing backend for Active Job (and separate queues per environment).
config.active_job.queue_adapter = :sidekiq
@ -101,7 +104,7 @@ Rails.application.configure do
# ]
# Skip DNS rebinding protection for the health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/api/v1/health" } }
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip)
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
config.hosts.concat(hosts) if hosts.present?

View file

@ -101,7 +101,7 @@ Rails.application.configure do
# ]
# Skip DNS rebinding protection for the health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == '/api/v1/health' } }
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',')
hosts = ENV.fetch('APPLICATION_HOSTS', 'localhost').split(',').map(&:strip)
config.action_mailer.default_url_options = { host: ENV['DOMAIN'] }
config.hosts.concat(hosts) if hosts.present?

View file

@ -4,7 +4,7 @@ settings = {
debug_mode: true,
timeout: 5,
units: :km,
cache: Redis.new(url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_CACHE_DB', 0)}"),
cache: Redis.new(url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_CACHE_DB', 0)),
always_raise: :all,
http_headers: {
'User-Agent' => "Dawarich #{APP_VERSION} (https://dawarich.app)"

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
Sidekiq.configure_server do |config|
config.redis = { url: "#{ENV['REDIS_URL']}/#{ENV.fetch('RAILS_JOB_QUEUE_DB', 1)}" }
config.redis = { url: ENV['REDIS_URL'], db: ENV.fetch('RAILS_JOB_QUEUE_DB', 1) }
config.logger = Sidekiq::Logger.new($stdout)
if ENV['PROMETHEUS_EXPORTER_ENABLED'].to_s == 'true'

View file

@ -1,26 +1,31 @@
# Mark existing migrations as safe
StrongMigrations.start_after = 20_250_122_150_500
# frozen_string_literal: true
# Set timeouts for migrations
# If you use PgBouncer in transaction mode, delete these lines and set timeouts on the database user
StrongMigrations.lock_timeout = 10.seconds
StrongMigrations.statement_timeout = 1.hour
# return unless Rails.env.development?
# Analyze tables after indexes are added
# Outdated statistics can sometimes hurt performance
StrongMigrations.auto_analyze = true
# # Mark existing migrations as safe
# StrongMigrations.start_after = 20_250_122_150_500
# Set the version of the production database
# so the right checks are run in development
# StrongMigrations.target_version = 10
# # Set timeouts for migrations
# # PgBouncer in transaction mode doesn't support SET commands
# # Timeouts should be set on the database user instead
# # StrongMigrations.lock_timeout = 10.seconds
# # StrongMigrations.statement_timeout = 1.hour
# Add custom checks
# StrongMigrations.add_check do |method, args|
# if method == :add_index && args[0].to_s == "users"
# stop! "No more indexes on the users table"
# end
# end
# # Analyze tables after indexes are added
# # Outdated statistics can sometimes hurt performance
# StrongMigrations.auto_analyze = true
# Make some operations safe by default
# See https://github.com/ankane/strong_migrations#safe-by-default
# StrongMigrations.safe_by_default = true
# # Set the version of the production database
# # so the right checks are run in development
# # StrongMigrations.target_version = 10
# # Add custom checks
# # StrongMigrations.add_check do |method, args|
# # if method == :add_index && args[0].to_s == "users"
# # stop! "No more indexes on the users table"
# # end
# # end
# # Make some operations safe by default
# # See https://github.com/ankane/strong_migrations#safe-by-default
# # StrongMigrations.safe_by_default = true

View file

@ -126,7 +126,11 @@ Rails.application.routes.draw do
get 'suggestions'
end
end
resources :points, only: %i[index create update destroy]
resources :points, only: %i[index create update destroy] do
collection do
delete :bulk_destroy
end
end
resources :visits, only: %i[index create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member
collection do

View file

@ -7,13 +7,14 @@ local:
root: <%= Rails.root.join("storage") %>
# Only load S3 config if not in test environment
<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] %>
<% if !Rails.env.test? && ENV['AWS_ACCESS_KEY_ID'] && ENV['AWS_SECRET_ACCESS_KEY'] && ENV['AWS_REGION'] && ENV['AWS_BUCKET'] && ENV['AWS_ENDPOINT_URL'] %>
s3:
service: S3
access_key_id: <%= ENV.fetch("AWS_ACCESS_KEY_ID") %>
secret_access_key: <%= ENV.fetch("AWS_SECRET_ACCESS_KEY") %>
region: <%= ENV.fetch("AWS_REGION") %>
bucket: <%= ENV.fetch("AWS_BUCKET") %>
endpoint: <%= ENV.fetch("AWS_ENDPOINT_URL") %>
<% end %>
# Remember not to checkin your GCS keyfile to a repository

View file

@ -2,10 +2,10 @@
class AddVisitedCountriesToTrips < ActiveRecord::Migration[8.0]
def change
safety_assured do
# safety_assured do
execute <<-SQL
ALTER TABLE trips ADD COLUMN visited_countries JSONB DEFAULT '{}'::jsonb NOT NULL;
SQL
end
# end
end
end

View file

@ -5,10 +5,10 @@ class AddH3HexIdsToStats < ActiveRecord::Migration[8.0]
def change
add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true
safety_assured do
# safety_assured do
add_index :stats, :h3_hex_ids, using: :gin,
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
algorithm: :concurrently, if_not_exists: true
end
# end
end
end

View file

@ -8,7 +8,7 @@ class CreateFamilies < ActiveRecord::Migration[8.0]
t.timestamps
end
add_foreign_key :families, :users, column: :creator_id, validate: false
add_foreign_key :families, :users, column: :creator_id
add_index :families, :creator_id
end
end

View file

@ -9,8 +9,8 @@ class CreateFamilyMemberships < ActiveRecord::Migration[8.0]
t.timestamps
end
add_foreign_key :family_memberships, :families, validate: false
add_foreign_key :family_memberships, :users, validate: false
add_foreign_key :family_memberships, :families
add_foreign_key :family_memberships, :users
add_index :family_memberships, :user_id, unique: true # One family per user
add_index :family_memberships, %i[family_id role], name: 'index_family_memberships_on_family_and_role'
end

View file

@ -12,8 +12,8 @@ class CreateFamilyInvitations < ActiveRecord::Migration[8.0]
t.timestamps
end
add_foreign_key :family_invitations, :families, validate: false
add_foreign_key :family_invitations, :users, column: :invited_by_id, validate: false
add_foreign_key :family_invitations, :families
add_foreign_key :family_invitations, :users, column: :invited_by_id
add_index :family_invitations, :token, unique: true
add_index :family_invitations, %i[family_id email], name: 'index_family_invitations_on_family_id_and_email'
add_index :family_invitations, %i[family_id status expires_at],

View file

@ -1,9 +1,10 @@
class ValidateFamilyForeignKeys < ActiveRecord::Migration[8.0]
def change
validate_foreign_key :families, :users
validate_foreign_key :family_memberships, :families
validate_foreign_key :family_memberships, :users
validate_foreign_key :family_invitations, :families
validate_foreign_key :family_invitations, :users
# No longer needed - foreign keys are now validated immediately in their creation migrations
# validate_foreign_key :families, :users
# validate_foreign_key :family_memberships, :families
# validate_foreign_key :family_memberships, :users
# validate_foreign_key :family_invitations, :families
# validate_foreign_key :family_invitations, :users
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class AddUtmParametersToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :utm_source, :string
add_column :users, :utm_medium, :string
add_column :users, :utm_campaign, :string
add_column :users, :utm_term, :string
add_column :users, :utm_content, :string
end
end

7
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_10_28_160950) do
ActiveRecord::Schema[8.0].define(version: 2025_10_30_190924) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -320,6 +320,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_28_160950) do
t.text "patreon_access_token"
t.text "patreon_refresh_token"
t.datetime "patreon_token_expires_at"
t.string "utm_source"
t.string "utm_medium"
t.string "utm_campaign"
t.string "utm_term"
t.string "utm_content"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end

141
docker/.env.example Normal file
View file

@ -0,0 +1,141 @@
# Dawarich Docker Compose Configuration
# Copy this file to .env and customize for your environment
# =============================================================================
# ENVIRONMENT CONFIGURATION
# =============================================================================
# Rails environment: development, staging, or production
RAILS_ENV=development
# =============================================================================
# DATABASE CONFIGURATION
# =============================================================================
# PostgreSQL credentials
POSTGRES_USER=postgres
POSTGRES_PASSWORD=password
# Database name
POSTGRES_DB=dawarich_development
# Database connection settings (used by Rails app)
DATABASE_HOST=dawarich_db
DATABASE_PORT=5432
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=password
DATABASE_NAME=dawarich_development
# =============================================================================
# REDIS CONFIGURATION
# =============================================================================
# Redis connection URL
REDIS_URL=redis://dawarich_redis:6379
# =============================================================================
# APPLICATION SETTINGS
# =============================================================================
# Port to expose the application on
DAWARICH_APP_PORT=3000
# Application hosts (comma-separated)
# Development: localhost
# Production: your-domain.com,www.your-domain.com
APPLICATION_HOSTS=localhost,::1,127.0.0.1
# Application protocol (http or https)
APPLICATION_PROTOCOL=http
# Time zone
TIME_ZONE=Europe/London
# Minimum minutes spent in city for statistics
MIN_MINUTES_SPENT_IN_CITY=60
# Self-hosted flag (true for docker deployments)
SELF_HOSTED=true
# Store geodata (reverse geocoding results)
STORE_GEODATA=true
# Storage backend (local or s3)
STORAGE_BACKEND=local
# =============================================================================
# SECURITY
# =============================================================================
# Secret key base for production/staging
# Generate with: openssl rand -hex 64
# Leave empty for development
# REQUIRED for production and staging environments
SECRET_KEY_BASE=
# =============================================================================
# BACKGROUND JOBS
# =============================================================================
# Sidekiq concurrency (number of threads)
BACKGROUND_PROCESSING_CONCURRENCY=10
# =============================================================================
# MONITORING & LOGGING
# =============================================================================
# Prometheus exporter settings
PROMETHEUS_EXPORTER_ENABLED=false
PROMETHEUS_EXPORTER_HOST=0.0.0.0
PROMETHEUS_EXPORTER_PORT=9394
PROMETHEUS_EXPORTER_HOST_SIDEKIQ=dawarich_app
# Uncomment to expose Prometheus port
# PROMETHEUS_PORT=9394
# Rails logging
RAILS_LOG_TO_STDOUT=true
# Docker logging settings
LOG_MAX_SIZE=100m
LOG_MAX_FILE=5
# =============================================================================
# RESOURCE LIMITS
# =============================================================================
# CPU and memory limits for the app container
APP_CPU_LIMIT=0.50
APP_MEMORY_LIMIT=4G
# =============================================================================
# EXAMPLE CONFIGURATIONS BY ENVIRONMENT
# =============================================================================
# --- DEVELOPMENT ---
# RAILS_ENV=development
# POSTGRES_DB=dawarich_development
# DATABASE_NAME=dawarich_development
# APPLICATION_HOSTS=localhost,::1,127.0.0.1
# APPLICATION_PROTOCOL=http
# SECRET_KEY_BASE=
# SELF_HOSTED=true
# --- STAGING ---
# RAILS_ENV=staging
# POSTGRES_DB=dawarich_staging
# DATABASE_NAME=dawarich_staging
# APPLICATION_HOSTS=staging.example.com
# APPLICATION_PROTOCOL=https
# SECRET_KEY_BASE=your-generated-secret-key
# SELF_HOSTED=true
# --- PRODUCTION ---
# RAILS_ENV=production
# POSTGRES_DB=dawarich_production
# DATABASE_NAME=dawarich_production
# APPLICATION_HOSTS=dawarich.example.com,www.dawarich.example.com
# APPLICATION_PROTOCOL=https
# SECRET_KEY_BASE=your-generated-secret-key
# SELF_HOSTED=true
# PROMETHEUS_EXPORTER_ENABLED=true

View file

@ -1,11 +1,12 @@
FROM ruby:3.4.6-slim
ARG RAILS_ENV=production
ENV APP_PATH=/var/app
ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_PORT=3000
ENV RAILS_ENV=production
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \
@ -25,12 +26,16 @@ RUN apt-get update -qq \
less \
libjemalloc2 libjemalloc-dev \
cmake \
&& curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g yarn \
ca-certificates \
&& mkdir -p $APP_PATH \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js from Debian repositories (supports all architectures including armv7)
RUN apt-get update -qq \
&& apt-get install -y nodejs npm \
&& npm install -g yarn \
&& rm -rf /var/lib/apt/lists/*
# Use jemalloc with check for architecture
RUN if [ "$(uname -m)" = "x86_64" ]; then \
echo "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \
@ -41,7 +46,7 @@ RUN if [ "$(uname -m)" = "x86_64" ]; then \
# Enable YJIT
ENV RUBY_YJIT_ENABLE=1
# Update gem system and install bundler
# Update RubyGems and install Bundler
RUN gem update --system 3.6.9 \
&& gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
@ -58,7 +63,7 @@ RUN bundle config set --local path 'vendor/bundle' \
COPY ../. ./
# Precompile assets for production
# Precompile assets
RUN SECRET_KEY_BASE_DUMMY=1 bundle exec rake assets:precompile \
&& rm -rf node_modules tmp/cache

View file

@ -1,87 +0,0 @@
FROM ruby:3.4.6-slim
ENV APP_PATH=/var/app
ENV BUNDLE_VERSION=2.5.21
ENV BUNDLE_PATH=/usr/local/bundle/gems
ENV RAILS_LOG_TO_STDOUT=true
ENV RAILS_PORT=3000
ENV RAILS_ENV=development
ENV SELF_HOSTED=true
ENV SIDEKIQ_USERNAME=sidekiq
ENV SIDEKIQ_PASSWORD=password
# Resolving sqlite3 error
ENV PGSSENCMODE=disable
RUN apt-get update -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get upgrade -y -qq \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl \
wget \
build-essential \
git \
postgresql-client \
libpq-dev \
libxml2-dev \
libxslt-dev \
libyaml-dev \
libgeos-dev libgeos++-dev \
imagemagick \
tzdata \
less \
libjemalloc2 libjemalloc-dev \
cmake \
ca-certificates \
&& mkdir -p $APP_PATH \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js using official NodeSource script
# NodeSource supports: amd64, arm64, armhf (arm/v7)
# For unsupported architectures, fall back to Debian's nodejs package
RUN ARCH=$(dpkg --print-architecture) && \
if [ "$ARCH" = "amd64" ] || [ "$ARCH" = "arm64" ] || [ "$ARCH" = "armhf" ]; then \
curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs; \
else \
apt-get update && \
apt-get install -y nodejs npm; \
fi && \
npm install -g yarn && \
rm -rf /var/lib/apt/lists/*
# Use jemalloc with check for architecture
RUN if [ "$(uname -m)" = "x86_64" ]; then \
echo "/usr/lib/x86_64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \
else \
echo "/usr/lib/aarch64-linux-gnu/libjemalloc.so.2" > /etc/ld.so.preload; \
fi
# Optional: Set YJIT explicitly (enabled by default in 3.4.1 MRI builds)
ENV RUBY_YJIT_ENABLE=1
# Update RubyGems and install Bundler
RUN gem update --system 3.6.9 \
&& gem install bundler --version "$BUNDLE_VERSION" \
&& rm -rf $GEM_HOME/cache/*
WORKDIR $APP_PATH
COPY ../Gemfile ../Gemfile.lock ../.ruby-version ../vendor ./
RUN bundle config set --local path 'vendor/bundle' \
&& bundle install --jobs 4 --retry 3 \
&& rm -rf vendor/bundle/ruby/3.4.0/cache/*.gem
COPY ../. ./
# Create caching-dev.txt file to enable Rails caching in development
RUN mkdir -p $APP_PATH/tmp && touch $APP_PATH/tmp/caching-dev.txt
COPY ./docker/web-entrypoint.sh /usr/local/bin/web-entrypoint.sh
RUN chmod +x /usr/local/bin/web-entrypoint.sh
COPY ./docker/sidekiq-entrypoint.sh /usr/local/bin/sidekiq-entrypoint.sh
RUN chmod +x /usr/local/bin/sidekiq-entrypoint.sh
EXPOSE $RAILS_PORT
ENTRYPOINT ["bundle", "exec"]

View file

@ -1,154 +0,0 @@
networks:
dawarich:
services:
dawarich_redis:
image: redis:7.4-alpine
container_name: dawarich_redis
command: redis-server
networks:
- dawarich
volumes:
- dawarich_redis_data:/data
restart: always
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
dawarich_db:
image: postgis/postgis:17-3.5-alpine
shm_size: 1G
container_name: dawarich_db
volumes:
- dawarich_db_data:/var/lib/postgresql/data
networks:
- dawarich
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: dawarich_production
restart: always
healthcheck:
test: [ "CMD", "pg_isready", "-U", "postgres" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
dawarich_app:
image: dawarich:prod
container_name: dawarich_app
volumes:
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
- dawarich_storage:/var/app/storage
- dawarich_db_data:/dawarich_db_data
networks:
- dawarich
ports:
- 3000:3000
# - 9394:9394 # Prometheus exporter, uncomment if needed
stdin_open: true
tty: true
entrypoint: web-entrypoint.sh
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
restart: on-failure
environment:
RAILS_ENV: production
REDIS_URL: redis://dawarich_redis:6379
DATABASE_HOST: dawarich_db
DATABASE_PORT: 5432
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_production
MIN_MINUTES_SPENT_IN_CITY: 60
APPLICATION_HOSTS: localhost,::1,127.0.0.1
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
SECRET_KEY_BASE: 1234567890
RAILS_LOG_TO_STDOUT: "true"
STORE_GEODATA: "true"
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ]
interval: 10s
retries: 30
start_period: 30s
timeout: 10s
depends_on:
dawarich_db:
condition: service_healthy
restart: true
dawarich_redis:
condition: service_healthy
restart: true
deploy:
resources:
limits:
cpus: '0.50' # Limit CPU usage to 50% of one core
memory: '4G' # Limit memory usage to 2GB
dawarich_sidekiq:
image: dawarich:prod
container_name: dawarich_sidekiq
volumes:
- dawarich_public:/var/app/public
- dawarich_watched:/var/app/tmp/imports/watched
- dawarich_storage:/var/app/storage
networks:
- dawarich
stdin_open: true
tty: true
entrypoint: sidekiq-entrypoint.sh
command: ['bundle', 'exec', 'sidekiq']
restart: on-failure
environment:
RAILS_ENV: production
REDIS_URL: redis://dawarich_redis:6379
DATABASE_HOST: dawarich_db
DATABASE_PORT: 5432
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_production
APPLICATION_HOSTS: localhost,::1,127.0.0.1
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: dawarich_app
PROMETHEUS_EXPORTER_PORT: 9394
SECRET_KEY_BASE: 1234567890
RAILS_LOG_TO_STDOUT: "true"
STORE_GEODATA: "true"
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: [ "CMD-SHELL", "pgrep -f sidekiq" ]
interval: 10s
retries: 30
start_period: 30s
timeout: 10s
depends_on:
dawarich_db:
condition: service_healthy
restart: true
dawarich_redis:
condition: service_healthy
restart: true
dawarich_app:
condition: service_healthy
restart: true
volumes:
dawarich_db_data:
dawarich_redis_data:
dawarich_public:
dawarich_watched:
dawarich_storage:

View file

@ -1,5 +1,6 @@
networks:
dawarich:
services:
dawarich_redis:
image: redis:7.4-alpine
@ -16,28 +17,30 @@ services:
retries: 5
start_period: 30s
timeout: 10s
dawarich_db:
image: postgis/postgis:17-3.5-alpine
# image: imresamu/postgis:17-3.5-alpine # If you're on ARM architecture, use this image instead
shm_size: 1G
container_name: dawarich_db
volumes:
- dawarich_db_data:/var/lib/postgresql/data
- dawarich_shared:/var/shared
# - ./postgresql.conf:/etc/postgresql/postgresql.conf # Optional, uncomment if you want to use a custom config
networks:
- dawarich
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: dawarich_development
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password}
POSTGRES_DB: ${POSTGRES_DB:-dawarich_development}
restart: always
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U postgres -d dawarich_development" ]
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-dawarich_development}" ]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
# command: postgres -c config_file=/etc/postgresql/postgresql.conf # Use custom config, uncomment if you want to use a custom config
dawarich_app:
image: freikin/dawarich:latest
container_name: dawarich_app
@ -49,34 +52,37 @@ services:
networks:
- dawarich
ports:
- 3000:3000
# - 9394:9394 # Prometheus exporter, uncomment if needed
- "${DAWARICH_APP_PORT:-3000}:3000"
# - "${PROMETHEUS_PORT:-9394}:9394" # Prometheus exporter, uncomment if needed
stdin_open: true
tty: true
entrypoint: web-entrypoint.sh
command: ['bin/rails', 'server', '-p', '3000', '-b', '::']
restart: on-failure
environment:
RAILS_ENV: development
REDIS_URL: redis://dawarich_redis:6379
DATABASE_HOST: dawarich_db
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
MIN_MINUTES_SPENT_IN_CITY: 60
APPLICATION_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
PROMETHEUS_EXPORTER_ENABLED: "false"
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394
SELF_HOSTED: "true"
STORE_GEODATA: "true"
RAILS_ENV: ${RAILS_ENV:-development}
REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379}
DATABASE_HOST: ${DATABASE_HOST:-dawarich_db}
DATABASE_PORT: ${DATABASE_PORT:-5432}
DATABASE_USERNAME: ${DATABASE_USERNAME:-postgres}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
DATABASE_NAME: ${DATABASE_NAME:-dawarich_development}
MIN_MINUTES_SPENT_IN_CITY: ${MIN_MINUTES_SPENT_IN_CITY:-60}
APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1}
TIME_ZONE: ${TIME_ZONE:-Europe/London}
APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http}
PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-false}
PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST:-0.0.0.0}
PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-"CHANGE_ME"}
RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-true}
SELF_HOSTED: ${SELF_HOSTED:-true}
STORE_GEODATA: ${STORE_GEODATA:-true}
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
max-size: ${LOG_MAX_SIZE:-100m}
max-file: ${LOG_MAX_FILE:-5}
healthcheck:
test: [ "CMD-SHELL", "wget -qO - http://127.0.0.1:3000/api/v1/health | grep -q '\"status\"\\s*:\\s*\"ok\"'" ]
interval: 10s
@ -93,8 +99,9 @@ services:
deploy:
resources:
limits:
cpus: '0.50' # Limit CPU usage to 50% of one core
memory: '4G' # Limit memory usage to 4GB
cpus: ${APP_CPU_LIMIT:-0.50}
memory: ${APP_MEMORY_LIMIT:-4G}
dawarich_sidekiq:
image: freikin/dawarich:latest
container_name: dawarich_sidekiq
@ -110,25 +117,28 @@ services:
command: ['sidekiq']
restart: on-failure
environment:
RAILS_ENV: development
REDIS_URL: redis://dawarich_redis:6379
DATABASE_HOST: dawarich_db
DATABASE_USERNAME: postgres
DATABASE_PASSWORD: password
DATABASE_NAME: dawarich_development
APPLICATION_HOSTS: localhost
BACKGROUND_PROCESSING_CONCURRENCY: 10
APPLICATION_PROTOCOL: http
PROMETHEUS_EXPORTER_ENABLED: "false"
PROMETHEUS_EXPORTER_HOST: dawarich_app
PROMETHEUS_EXPORTER_PORT: 9394
SELF_HOSTED: "true"
STORE_GEODATA: "true"
RAILS_ENV: ${RAILS_ENV:-development}
REDIS_URL: ${REDIS_URL:-redis://dawarich_redis:6379}
DATABASE_HOST: ${DATABASE_HOST:-dawarich_db}
DATABASE_PORT: ${DATABASE_PORT:-5432}
DATABASE_USERNAME: ${DATABASE_USERNAME:-postgres}
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password}
DATABASE_NAME: ${DATABASE_NAME:-dawarich_development}
APPLICATION_HOSTS: ${APPLICATION_HOSTS:-localhost,::1,127.0.0.1}
BACKGROUND_PROCESSING_CONCURRENCY: ${BACKGROUND_PROCESSING_CONCURRENCY:-10}
APPLICATION_PROTOCOL: ${APPLICATION_PROTOCOL:-http}
PROMETHEUS_EXPORTER_ENABLED: ${PROMETHEUS_EXPORTER_ENABLED:-false}
PROMETHEUS_EXPORTER_HOST: ${PROMETHEUS_EXPORTER_HOST_SIDEKIQ:-dawarich_app}
PROMETHEUS_EXPORTER_PORT: ${PROMETHEUS_EXPORTER_PORT:-9394}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-"CHANGE_ME"}
RAILS_LOG_TO_STDOUT: ${RAILS_LOG_TO_STDOUT:-true}
SELF_HOSTED: ${SELF_HOSTED:-true}
STORE_GEODATA: ${STORE_GEODATA:-true}
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
max-size: ${LOG_MAX_SIZE:-100m}
max-file: ${LOG_MAX_FILE:-5}
healthcheck:
test: [ "CMD-SHELL", "pgrep -f sidekiq" ]
interval: 10s

115
e2e/README.md Normal file
View file

@ -0,0 +1,115 @@
# E2E Tests
End-to-end tests for Dawarich using Playwright.
## Running Tests
```bash
# Run all tests
npx playwright test
# Run specific test file
npx playwright test e2e/map/map-controls.spec.js
# Run tests in headed mode (watch browser)
npx playwright test --headed
# Run tests in debug mode
npx playwright test --debug
# Run tests sequentially (avoid parallel issues)
npx playwright test --workers=1
```
## Structure
```
e2e/
├── setup/ # Test setup and authentication
├── helpers/ # Shared helper functions
├── map/ # Map-related tests (40 tests total)
└── temp/ # Playwright artifacts (screenshots, videos)
```
### Test Files
**Map Tests (62 tests)**
- `map-controls.spec.js` - Basic map controls, zoom, tile layers (5 tests)
- `map-layers.spec.js` - Layer toggles: Routes, Heatmap, Fog, etc. (8 tests)
- `map-points.spec.js` - Point interactions and deletion (4 tests)
- `map-visits.spec.js` - Confirmed visit interactions and management (5 tests)
- `map-suggested-visits.spec.js` - Suggested visit interactions (confirm/decline) (6 tests)
- `map-add-visit.spec.js` - Add visit control and form (8 tests)
- `map-selection-tool.spec.js` - Selection tool functionality (4 tests)
- `map-calendar-panel.spec.js` - Calendar panel navigation (9 tests)
- `map-side-panel.spec.js` - Side panel (visits drawer) functionality (13 tests)*
- `map-bulk-delete.spec.js` - Bulk point deletion (12 tests)
\* Some side panel tests may be skipped if demo data doesn't contain visits
## Helper Functions
### Map Helpers (`helpers/map.js`)
- `waitForMap(page)` - Wait for Leaflet map initialization
- `enableLayer(page, layerName)` - Enable a map layer by name
- `clickConfirmedVisit(page)` - Click first confirmed visit circle
- `clickSuggestedVisit(page)` - Click first suggested visit circle
- `getMapZoom(page)` - Get current map zoom level
### Navigation Helpers (`helpers/navigation.js`)
- `closeOnboardingModal(page)` - Close getting started modal
- `navigateToDate(page, startDate, endDate)` - Navigate to specific date range
- `navigateToMap(page)` - Navigate to map page with setup
### Selection Helpers (`helpers/selection.js`)
- `drawSelectionRectangle(page, options)` - Draw selection on map
- `enableSelectionMode(page)` - Enable area selection tool
## Common Patterns
### Basic Test Template
```javascript
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap } from '../helpers/map.js';
test('my test', async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
// Your test logic
});
```
### Testing Map Layers
```javascript
import { enableLayer } from '../helpers/map.js';
await enableLayer(page, 'Routes');
await enableLayer(page, 'Heatmap');
```
## Debugging
### View Test Artifacts
```bash
# Open HTML report
npx playwright show-report
# Screenshots and videos are in:
test-results/
```
### Common Issues
- **Flaky tests**: Run with `--workers=1` to avoid parallel interference
- **Timeout errors**: Increase timeout in test or use `page.waitForTimeout()`
- **Map not loading**: Ensure `waitForMap()` is called after navigation
## CI/CD
Tests run with:
- 1 worker (sequential)
- 2 retries on failure
- Screenshots/videos on failure
- JUnit XML reports
See `playwright.config.js` for full configuration.

84
e2e/helpers/map.js Normal file
View file

@ -0,0 +1,84 @@
/**
* Map helper functions for Playwright tests
*/
/**
* Wait for Leaflet map to be fully initialized
* @param {Page} page - Playwright page object
*/
export async function waitForMap(page) {
await page.waitForFunction(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container && container._leaflet_id !== undefined;
}, { timeout: 10000 });
}
/**
* Enable a map layer by name
* @param {Page} page - Playwright page object
* @param {string} layerName - Name of the layer to enable (e.g., "Routes", "Heatmap")
*/
export async function enableLayer(page, layerName) {
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const checkbox = page.locator(`.leaflet-control-layers-overlays label:has-text("${layerName}") input[type="checkbox"]`);
const isChecked = await checkbox.isChecked();
if (!isChecked) {
await checkbox.check();
await page.waitForTimeout(1000);
}
}
/**
* Click on the first confirmed visit circle on the map
* @param {Page} page - Playwright page object
* @returns {Promise<boolean>} - True if a visit was clicked, false otherwise
*/
export async function clickConfirmedVisit(page) {
return await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
const layers = controller.visitsManager.confirmedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit) {
firstVisit.fire('click');
return true;
}
}
return false;
});
}
/**
* Click on the first suggested visit circle on the map
* @param {Page} page - Playwright page object
* @returns {Promise<boolean>} - True if a visit was clicked, false otherwise
*/
export async function clickSuggestedVisit(page) {
return await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
const layers = controller.visitsManager.suggestedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit) {
firstVisit.fire('click');
return true;
}
}
return false;
});
}
/**
* Get current map zoom level
* @param {Page} page - Playwright page object
* @returns {Promise<number|null>} - Current zoom level or null
*/
export async function getMapZoom(page) {
return await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.getZoom() || null;
});
}

45
e2e/helpers/navigation.js Normal file
View file

@ -0,0 +1,45 @@
/**
* Navigation and UI helper functions for Playwright tests
*/
/**
* Close the onboarding modal if it's open
* @param {Page} page - Playwright page object
*/
export async function closeOnboardingModal(page) {
const onboardingModal = page.locator('#getting_started');
const isModalOpen = await onboardingModal.evaluate((dialog) => dialog.open).catch(() => false);
if (isModalOpen) {
await page.locator('#getting_started button.btn-primary').click();
await page.waitForTimeout(500);
}
}
/**
* Navigate to the map page and close onboarding modal
* @param {Page} page - Playwright page object
*/
export async function navigateToMap(page) {
await page.goto('/map');
await closeOnboardingModal(page);
}
/**
* Navigate to a specific date range on the map
* @param {Page} page - Playwright page object
* @param {string} startDate - Start date in format 'YYYY-MM-DDTHH:mm'
* @param {string} endDate - End date in format 'YYYY-MM-DDTHH:mm'
*/
export async function navigateToDate(page, startDate, endDate) {
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill(startDate);
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill(endDate);
await page.click('input[type="submit"][value="Search"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}

64
e2e/helpers/selection.js Normal file
View file

@ -0,0 +1,64 @@
/**
* Selection and drawing helper functions for Playwright tests
*/
/**
* Enable selection mode by clicking the selection tool button
* @param {Page} page - Playwright page object
*/
export async function enableSelectionMode(page) {
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
}
/**
* Draw a selection rectangle on the map
* @param {Page} page - Playwright page object
* @param {Object} options - Drawing options
* @param {number} options.startX - Start X position (0-1 as fraction of width, default: 0.2)
* @param {number} options.startY - Start Y position (0-1 as fraction of height, default: 0.2)
* @param {number} options.endX - End X position (0-1 as fraction of width, default: 0.8)
* @param {number} options.endY - End Y position (0-1 as fraction of height, default: 0.8)
* @param {number} options.steps - Number of steps for smooth drag (default: 10)
*/
export async function drawSelectionRectangle(page, options = {}) {
const {
startX = 0.2,
startY = 0.2,
endX = 0.8,
endY = 0.8,
steps = 10
} = options;
// Click area selection tool
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Get map container bounding box
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Calculate absolute positions
const absStartX = bbox.x + bbox.width * startX;
const absStartY = bbox.y + bbox.height * startY;
const absEndX = bbox.x + bbox.width * endX;
const absEndY = bbox.y + bbox.height * endY;
// Draw rectangle
await page.mouse.move(absStartX, absStartY);
await page.mouse.down();
await page.mouse.move(absEndX, absEndY, { steps });
await page.mouse.up();
// Wait for API calls and drawer animations
await page.waitForTimeout(2000);
// Wait for drawer to open (it should open automatically after selection)
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
// Wait for delete button to appear in the drawer (indicates selection is complete)
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
await page.waitForTimeout(500); // Brief wait for UI to stabilize
}

View file

@ -1,134 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Test to verify the refactored LiveMapHandler class works correctly
*/
test.describe('LiveMapHandler Refactoring', () => {
let page;
let context;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
// Sign in
await page.goto('/users/sign_in');
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
await page.click('input[type="submit"][value="Log in"]');
await page.waitForURL('/map', { timeout: 10000 });
});
test.afterAll(async () => {
await page.close();
await context.close();
});
test('should have LiveMapHandler class imported and available', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Check if LiveMapHandler is available in the code
const hasLiveMapHandler = await page.evaluate(() => {
// Check if the LiveMapHandler class exists in the bundled JavaScript
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
const allJavaScript = scripts.join(' ');
const hasLiveMapHandlerClass = allJavaScript.includes('LiveMapHandler') ||
allJavaScript.includes('live_map_handler');
const hasAppendPointDelegation = allJavaScript.includes('liveMapHandler.appendPoint') ||
allJavaScript.includes('this.liveMapHandler');
return {
hasLiveMapHandlerClass,
hasAppendPointDelegation,
totalJSSize: allJavaScript.length,
scriptCount: scripts.length
};
});
console.log('LiveMapHandler availability:', hasLiveMapHandler);
// The test is informational - we verify the refactoring is present in source
expect(hasLiveMapHandler.scriptCount).toBeGreaterThan(0);
});
test('should have proper delegation in maps controller', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Verify the controller structure
const controllerAnalysis = await page.evaluate(() => {
const mapElement = document.querySelector('#map');
const controllers = mapElement?._stimulus_controllers;
const mapController = controllers?.find(c => c.identifier === 'maps');
if (mapController) {
const hasAppendPoint = typeof mapController.appendPoint === 'function';
const methodSource = hasAppendPoint ? mapController.appendPoint.toString() : '';
return {
hasController: true,
hasAppendPoint,
// Check if appendPoint delegates to LiveMapHandler
usesDelegation: methodSource.includes('liveMapHandler') || methodSource.includes('LiveMapHandler'),
methodLength: methodSource.length,
isSimpleMethod: methodSource.length < 500 // Should be much smaller now
};
}
return {
hasController: false,
message: 'Controller not found in test environment'
};
});
console.log('Controller delegation analysis:', controllerAnalysis);
// Test passes either way since we've implemented the refactoring
if (controllerAnalysis.hasController) {
// If controller exists, verify it's using delegation
expect(controllerAnalysis.hasAppendPoint).toBe(true);
// The new appendPoint method should be much smaller (delegation only)
expect(controllerAnalysis.isSimpleMethod).toBe(true);
} else {
// Controller not found - this is the current test environment limitation
console.log('Controller not accessible in test, but refactoring implemented in source');
}
expect(true).toBe(true); // Test always passes as verification
});
test('should maintain backward compatibility', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Verify basic map functionality still works
const mapFunctionality = await page.evaluate(() => {
return {
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
hasMapElement: !!document.querySelector('#map'),
hasApiKey: !!document.querySelector('#map')?.dataset?.api_key,
leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length,
hasDataController: document.querySelector('#map')?.hasAttribute('data-controller')
};
});
console.log('Map functionality check:', mapFunctionality);
// Verify all core functionality remains intact
expect(mapFunctionality.hasLeafletContainer).toBe(true);
expect(mapFunctionality.hasMapElement).toBe(true);
expect(mapFunctionality.hasApiKey).toBe(true);
expect(mapFunctionality.hasDataController).toBe(true);
expect(mapFunctionality.leafletElementCount).toBeGreaterThan(10);
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,260 @@
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap } from '../helpers/map.js';
/**
* Helper to wait for add visit controller to be fully initialized
*/
async function waitForAddVisitController(page) {
await page.waitForTimeout(2000); // Wait for controller to connect and attach handlers
}
test.describe('Add Visit Control', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
await waitForAddVisitController(page);
});
test('should show add visit button control', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await expect(addVisitButton).toBeVisible();
});
test('should enable add visit mode when clicked', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await addVisitButton.click();
await page.waitForTimeout(1000);
// Verify flash message appears
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Click on the map")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Verify cursor changed to crosshair
const cursor = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor;
});
expect(cursor).toBe('crosshair');
// Verify button has active state (background color applied)
const hasActiveStyle = await addVisitButton.evaluate((el) => {
return el.style.backgroundColor !== '';
});
expect(hasActiveStyle).toBe(true);
});
test('should open popup form when map is clicked', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await addVisitButton.click();
await page.waitForTimeout(500);
// Click on map - use bottom left corner which is less likely to have points
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible({ timeout: 10000 });
// Verify popup contains the add visit form
await expect(popup.locator('h3:has-text("Add New Visit")')).toBeVisible();
// Verify marker appears (📍 emoji with class add-visit-marker)
const marker = page.locator('.add-visit-marker');
await expect(marker).toBeVisible();
});
test('should display correct form content in popup', async ({ page }) => {
// Enable mode and click map
await page.locator('.add-visit-button').click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Verify popup content has all required elements
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible();
await expect(popupContent.locator('input#visit-name')).toBeVisible();
await expect(popupContent.locator('input#visit-start')).toBeVisible();
await expect(popupContent.locator('input#visit-end')).toBeVisible();
await expect(popupContent.locator('button:has-text("Create Visit")')).toBeVisible();
await expect(popupContent.locator('button:has-text("Cancel")')).toBeVisible();
// Verify name field has focus
const nameFieldFocused = await page.evaluate(() => {
return document.activeElement?.id === 'visit-name';
});
expect(nameFieldFocused).toBe(true);
// Verify start and end time have default values
const startValue = await page.locator('input#visit-start').inputValue();
const endValue = await page.locator('input#visit-end').inputValue();
expect(startValue).toBeTruthy();
expect(endValue).toBeTruthy();
});
test('should hide popup and remove marker when cancel is clicked', async ({ page }) => {
// Enable mode and click map
await page.locator('.add-visit-button').click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Verify popup and marker exist
await expect(page.locator('.leaflet-popup')).toBeVisible();
await expect(page.locator('.add-visit-marker')).toBeVisible();
// Click cancel button
await page.locator('#cancel-visit').click();
await page.waitForTimeout(500);
// Verify popup is hidden
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify marker is removed
const markerCount = await page.locator('.add-visit-marker').count();
expect(markerCount).toBe(0);
// Verify cursor is reset to default
const cursor = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor;
});
expect(cursor).toBe('');
// Verify mode was exited (cursor should be reset)
const cursorReset = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor === '';
});
expect(cursorReset).toBe(true);
});
test('should create visit and show marker on map when submitted', async ({ page }) => {
// Get initial confirmed visit count
const initialCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
// Enable mode and click map
await page.locator('.add-visit-button').click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Fill form with unique visit name
const visitName = `E2E Test Visit ${Date.now()}`;
await page.locator('#visit-name').fill(visitName);
// Submit form
await page.locator('button:has-text("Create Visit")').click();
await page.waitForTimeout(2000);
// Verify success message
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("created successfully")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Verify popup is closed
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify confirmed visit marker count increased
const finalCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
expect(finalCount).toBeGreaterThan(initialCount);
});
test('should disable add visit mode when clicked second time', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
// First click - enable mode
await addVisitButton.click();
await page.waitForTimeout(500);
// Verify mode is enabled
const cursorEnabled = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor === 'crosshair';
});
expect(cursorEnabled).toBe(true);
// Second click - disable mode
await addVisitButton.click();
await page.waitForTimeout(500);
// Verify cursor is reset
const cursorDisabled = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor;
});
expect(cursorDisabled).toBe('');
// Verify mode was exited by checking if we can click map without creating marker
const isAddingVisit = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'add-visit');
return controller?.isAddingVisit === true;
});
expect(isAddingVisit).toBe(false);
});
test('should ensure only one visit popup is open at a time', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await addVisitButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Click first location on map
await page.mouse.click(bbox.x + bbox.width * 0.3, bbox.y + bbox.height * 0.3);
await page.waitForTimeout(500);
// Verify first popup exists
let popupCount = await page.locator('.leaflet-popup').count();
expect(popupCount).toBe(1);
// Get the content of first popup to verify it exists
const firstPopupContent = await page.locator('.leaflet-popup-content input#visit-name').count();
expect(firstPopupContent).toBe(1);
// Click second location on map
await page.mouse.click(bbox.x + bbox.width * 0.7, bbox.y + bbox.height * 0.7);
await page.waitForTimeout(500);
// Verify still only one popup exists (old one was closed, new one opened)
popupCount = await page.locator('.leaflet-popup').count();
expect(popupCount).toBe(1);
// Verify the popup contains the add visit form (not some other popup)
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible();
await expect(popupContent.locator('input#visit-name')).toBeVisible();
// Verify only one marker exists
const markerCount = await page.locator('.add-visit-marker').count();
expect(markerCount).toBe(1);
});
});

View file

@ -0,0 +1,380 @@
import { test, expect } from '@playwright/test';
import { drawSelectionRectangle } from '../helpers/selection.js';
import { navigateToDate, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer } from '../helpers/map.js';
test.describe('Bulk Delete Points', () => {
test.beforeEach(async ({ page }) => {
// Navigate to map page
await page.goto('/map', {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Wait for map to be initialized
await waitForMap(page);
// Close onboarding modal if present
await closeOnboardingModal(page);
// Navigate to a date with points (October 13, 2024)
await navigateToDate(page, '2024-10-13T00:00', '2024-10-13T23:59');
// Enable Points layer
await enableLayer(page, 'Points');
});
test('should show area selection tool button', async ({ page }) => {
// Check that area selection button exists
const selectionButton = page.locator('#selection-tool-button');
await expect(selectionButton).toBeVisible();
});
test('should enable selection mode when area tool is clicked', async ({ page }) => {
// Click area selection button
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is active
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.selectionMode === true;
});
expect(isSelectionActive).toBe(true);
});
test('should select points in drawn area and show delete button', async ({ page }) => {
await drawSelectionRectangle(page);
// Check that delete button appears
const deleteButton = page.locator('#delete-selection-button');
await expect(deleteButton).toBeVisible({ timeout: 10000 });
// Check button has text "Delete Points"
await expect(deleteButton).toContainText('Delete Points');
});
test('should show point count badge on delete button', async ({ page }) => {
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Check for badge with count
const badge = page.locator('#delete-selection-button .badge');
await expect(badge).toBeVisible();
// Badge should contain a number
const badgeText = await badge.textContent();
expect(parseInt(badgeText)).toBeGreaterThan(0);
});
test('should show cancel button alongside delete button', async ({ page }) => {
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Check both buttons exist
const cancelButton = page.locator('#cancel-selection-button');
const deleteButton = page.locator('#delete-selection-button');
await expect(cancelButton).toBeVisible();
await expect(deleteButton).toBeVisible();
await expect(cancelButton).toContainText('Cancel');
});
test('should cancel selection when cancel button is clicked', async ({ page }) => {
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Click cancel button
const cancelButton = page.locator('#cancel-selection-button');
await cancelButton.click();
await page.waitForTimeout(500);
// Verify buttons are gone
await expect(cancelButton).not.toBeVisible();
await expect(page.locator('#delete-selection-button')).not.toBeVisible();
// Verify selection is cleared
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === false;
});
expect(isSelectionActive).toBe(true);
});
test('should show confirmation dialog when delete button is clicked', async ({ page }) => {
// Set up dialog handler
let dialogMessage = '';
page.on('dialog', async dialog => {
dialogMessage = dialog.message();
await dialog.dismiss(); // Dismiss to prevent actual deletion
});
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Click delete button
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(500);
// Verify confirmation dialog appeared with warning
expect(dialogMessage).toContain('WARNING');
expect(dialogMessage).toContain('permanently delete');
expect(dialogMessage).toContain('cannot be undone');
});
test('should delete points and show success message when confirmed', async ({ page }) => {
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Get initial point count
const initialPointCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.markers?.length || 0;
});
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Click delete button
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000); // Wait for deletion to complete
// Check for success flash message with specific text
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Successfully deleted")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
const messageText = await flashMessage.textContent();
expect(messageText).toMatch(/Successfully deleted \d+ point/);
// Verify point count decreased
const finalPointCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.markers?.length || 0;
});
expect(finalPointCount).toBeLessThan(initialPointCount);
});
test('should preserve Routes layer disabled state after deletion', async ({ page }) => {
// Ensure Routes layer is disabled
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
const isRoutesChecked = await routesCheckbox.isChecked();
if (isRoutesChecked) {
await routesCheckbox.uncheck();
await page.waitForTimeout(500);
}
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify Routes layer is still disabled
const isRoutesLayerVisible = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.hasLayer(controller?.polylinesLayer);
});
expect(isRoutesLayerVisible).toBe(false);
});
test('should preserve Routes layer enabled state after deletion', async ({ page }) => {
// Enable Routes layer
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
const isRoutesChecked = await routesCheckbox.isChecked();
if (!isRoutesChecked) {
await routesCheckbox.check();
await page.waitForTimeout(1000);
}
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify Routes layer is still enabled
const isRoutesLayerVisible = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.hasLayer(controller?.polylinesLayer);
});
expect(isRoutesLayerVisible).toBe(true);
});
test('should update heatmap after bulk deletion', async ({ page }) => {
// Enable Heatmap layer
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const heatmapCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Heatmap") input[type="checkbox"]');
const isHeatmapChecked = await heatmapCheckbox.isChecked();
if (!isHeatmapChecked) {
await heatmapCheckbox.check();
await page.waitForTimeout(1000);
}
// Get initial heatmap data count
const initialHeatmapCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.heatmapLayer?._latlngs?.length || 0;
});
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify heatmap was updated
const finalHeatmapCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.heatmapLayer?._latlngs?.length || 0;
});
expect(finalHeatmapCount).toBeLessThan(initialHeatmapCount);
});
test('should clear selection after successful deletion', async ({ page }) => {
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify selection is cleared
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === false &&
controller?.visitsManager?.selectedPoints?.length === 0;
});
expect(isSelectionActive).toBe(true);
// Verify buttons are removed
await expect(page.locator('#cancel-selection-button')).not.toBeVisible();
await expect(page.locator('#delete-selection-button')).not.toBeVisible();
});
});

View file

@ -0,0 +1,308 @@
import { test, expect } from '@playwright/test';
import { closeOnboardingModal } from '../helpers/navigation.js';
/**
* Calendar Panel Tests
*
* Tests for the calendar panel control that allows users to navigate between
* different years and months. The panel is opened via the "Toggle Panel" button
* in the top-right corner of the map.
*/
test.describe('Calendar Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/map');
await closeOnboardingModal(page);
// Wait for map to be fully loaded
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
await page.waitForTimeout(2000); // Wait for all controls to be initialized
});
/**
* Helper function to find and click the calendar toggle button
*/
async function clickCalendarButton(page) {
// The calendar button is the "Toggle Panel" button with a calendar icon
// It's the third button in the top-right control stack (after Select Area and Add Visit)
const calendarButton = await page.locator('button.toggle-panel-button').first();
await expect(calendarButton).toBeVisible({ timeout: 5000 });
await calendarButton.click();
await page.waitForTimeout(500); // Wait for panel animation
}
/**
* Helper function to check if panel is visible
*/
async function isPanelVisible(page) {
const panel = page.locator('.leaflet-right-panel');
const isVisible = await panel.isVisible().catch(() => false);
if (!isVisible) return false;
const displayStyle = await panel.evaluate(el => el.style.display);
return displayStyle !== 'none';
}
test('should open calendar panel on first click', async ({ page }) => {
// Verify panel is not visible initially
const initiallyVisible = await isPanelVisible(page);
expect(initiallyVisible).toBe(false);
// Click calendar button
await clickCalendarButton(page);
// Verify panel is now visible
const panelVisible = await isPanelVisible(page);
expect(panelVisible).toBe(true);
// Verify panel contains expected elements
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible();
const monthsGrid = page.locator('#months-grid');
await expect(monthsGrid).toBeVisible();
// Verify "Whole year" link is present
const wholeYearLink = page.locator('#whole-year-link');
await expect(wholeYearLink).toBeVisible();
});
test('should close calendar panel on second click', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
await page.waitForTimeout(300);
// Verify panel is visible
let panelVisible = await isPanelVisible(page);
expect(panelVisible).toBe(true);
// Click button again to close
await clickCalendarButton(page);
await page.waitForTimeout(300);
// Verify panel is hidden
panelVisible = await isPanelVisible(page);
expect(panelVisible).toBe(false);
});
test('should allow year selection', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for year select to be populated (it loads from API)
await page.waitForTimeout(2000);
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible();
// Get available years
const options = await yearSelect.locator('option:not([disabled])').all();
// Should have at least one year available
expect(options.length).toBeGreaterThan(0);
// Select the first available year
const firstYearOption = options[0];
const yearValue = await firstYearOption.getAttribute('value');
await yearSelect.selectOption(yearValue);
// Verify year was selected
const selectedValue = await yearSelect.inputValue();
expect(selectedValue).toBe(yearValue);
});
test('should navigate to month when clicking month button', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for months to load
await page.waitForTimeout(3000);
// Select year 2024 (which has October data in demo)
const yearSelect = page.locator('#year-select');
await yearSelect.selectOption('2024');
await page.waitForTimeout(500);
// Find October button (demo data has October 2024)
const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]');
await expect(octoberButton).toBeVisible({ timeout: 5000 });
// Verify October is enabled (not disabled)
const isDisabled = await octoberButton.evaluate(el => el.classList.contains('disabled'));
expect(isDisabled).toBe(false);
// Verify button is clickable
const pointerEvents = await octoberButton.evaluate(el => el.style.pointerEvents);
expect(pointerEvents).not.toBe('none');
// Get the expected href before clicking
const expectedHref = await octoberButton.getAttribute('href');
expect(expectedHref).toBeTruthy();
const decodedHref = decodeURIComponent(expectedHref);
expect(decodedHref).toContain('map?');
expect(decodedHref).toContain('start_at=2024-10-01T00:00');
expect(decodedHref).toContain('end_at=2024-10-31T23:59');
// Click the month button and wait for navigation
await Promise.all([
page.waitForURL('**/map**', { timeout: 10000 }),
octoberButton.click()
]);
// Wait for page to settle
await page.waitForLoadState('networkidle', { timeout: 10000 });
// Verify we navigated to the map page
expect(page.url()).toContain('/map');
// Verify map loaded with data
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
});
test('should navigate to whole year when clicking "Whole year" button', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for panel to load
await page.waitForTimeout(2000);
const wholeYearLink = page.locator('#whole-year-link');
await expect(wholeYearLink).toBeVisible();
// Get the href and decode it
const href = await wholeYearLink.getAttribute('href');
expect(href).toBeTruthy();
const decodedHref = decodeURIComponent(href);
expect(decodedHref).toContain('map?');
expect(decodedHref).toContain('start_at=');
expect(decodedHref).toContain('end_at=');
// Href should contain full year dates (01-01 to 12-31)
expect(decodedHref).toContain('-01-01T00:00');
expect(decodedHref).toContain('-12-31T23:59');
// Store the expected year from the href
const yearMatch = decodedHref.match(/(\d{4})-01-01/);
expect(yearMatch).toBeTruthy();
const expectedYear = yearMatch[1];
// Click the link and wait for navigation
await Promise.all([
page.waitForURL('**/map**', { timeout: 10000 }),
wholeYearLink.click()
]);
// Wait for page to settle
await page.waitForLoadState('networkidle', { timeout: 10000 });
// Verify we navigated to the map page
expect(page.url()).toContain('/map');
// The URL parameters might be processed differently (e.g., stripped by Turbo or redirected)
// Instead of checking URL, verify the panel updates to show the whole year is selected
// by checking the year in the select dropdown
const panelVisible = await isPanelVisible(page);
if (!panelVisible) {
// Panel might have closed on navigation, reopen it
await clickCalendarButton(page);
await page.waitForTimeout(1000);
}
const yearSelect = page.locator('#year-select');
const selectedYear = await yearSelect.inputValue();
expect(selectedYear).toBe(expectedYear);
});
test('should update month buttons when year is changed', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for data to load
await page.waitForTimeout(2000);
const yearSelect = page.locator('#year-select');
// Get available years
const options = await yearSelect.locator('option:not([disabled])').all();
if (options.length < 2) {
console.log('Test skipped: Less than 2 years available');
test.skip();
return;
}
// Select first year and capture month states
const firstYearOption = options[0];
const firstYear = await firstYearOption.getAttribute('value');
await yearSelect.selectOption(firstYear);
await page.waitForTimeout(500);
// Get enabled months for first year
const firstYearMonths = await page.locator('#months-grid a:not(.disabled)').count();
// Select second year
const secondYearOption = options[1];
const secondYear = await secondYearOption.getAttribute('value');
await yearSelect.selectOption(secondYear);
await page.waitForTimeout(500);
// Get enabled months for second year
const secondYearMonths = await page.locator('#months-grid a:not(.disabled)').count();
// Months should be different (unless both years have same tracked months)
// At minimum, verify that month buttons are updated (content changed from loading dots)
const monthButtons = await page.locator('#months-grid a').all();
for (const button of monthButtons) {
const buttonText = await button.textContent();
// Should not contain loading dots anymore
expect(buttonText).not.toContain('loading');
}
});
test('should highlight active month based on current URL parameters', async ({ page }) => {
// Navigate to a specific month first
await page.goto('/map?start_at=2024-10-01T00:00&end_at=2024-10-31T23:59');
await closeOnboardingModal(page);
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
await page.waitForTimeout(2000);
// Open calendar panel
await clickCalendarButton(page);
await page.waitForTimeout(2000);
// Find October button (month index 9, displayed as "Oct")
const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]');
await expect(octoberButton).toBeVisible();
// Verify October is marked as active
const hasActiveClass = await octoberButton.evaluate(el =>
el.classList.contains('btn-active')
);
expect(hasActiveClass).toBe(true);
});
test('should show visited cities section in panel', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
await page.waitForTimeout(2000);
// Verify visited cities section is present
const visitedCitiesContainer = page.locator('#visited-cities-container');
await expect(visitedCitiesContainer).toBeVisible();
const visitedCitiesTitle = visitedCitiesContainer.locator('h3');
await expect(visitedCitiesTitle).toHaveText('Visited cities');
const visitedCitiesList = page.locator('#visited-cities-list');
await expect(visitedCitiesList).toBeVisible();
// List should eventually load (either with cities or "No places visited")
await page.waitForTimeout(2000);
const listContent = await visitedCitiesList.textContent();
expect(listContent.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,157 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal, navigateToDate } from '../helpers/navigation.js';
import { waitForMap, getMapZoom } from '../helpers/map.js';
test.describe('Map Page', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
});
test('should load map container and display map with controls', async ({ page }) => {
await expect(page.locator('#map')).toBeVisible();
await waitForMap(page);
// Verify zoom controls are present
await expect(page.locator('.leaflet-control-zoom')).toBeVisible();
// Verify custom map controls are present (from map_controls.js)
await expect(page.locator('.add-visit-button')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.toggle-panel-button')).toBeVisible();
await expect(page.locator('.drawer-button')).toBeVisible();
await expect(page.locator('#selection-tool-button')).toBeVisible();
});
test('should zoom in when clicking zoom in button', async ({ page }) => {
await waitForMap(page);
const initialZoom = await getMapZoom(page);
await page.locator('.leaflet-control-zoom-in').click();
await page.waitForTimeout(500);
const newZoom = await getMapZoom(page);
expect(newZoom).toBeGreaterThan(initialZoom);
});
test('should zoom out when clicking zoom out button', async ({ page }) => {
await waitForMap(page);
const initialZoom = await getMapZoom(page);
await page.locator('.leaflet-control-zoom-out').click();
await page.waitForTimeout(500);
const newZoom = await getMapZoom(page);
expect(newZoom).toBeLessThan(initialZoom);
});
test('should switch between map tile layers', async ({ page }) => {
await waitForMap(page);
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const getSelectedLayer = () => page.evaluate(() => {
const radio = document.querySelector('.leaflet-control-layers-base input[type="radio"]:checked');
return radio ? radio.nextSibling.textContent.trim() : null;
});
const initialLayer = await getSelectedLayer();
await page.locator('.leaflet-control-layers-base input[type="radio"]:not(:checked)').first().click();
await page.waitForTimeout(500);
const newLayer = await getSelectedLayer();
expect(newLayer).not.toBe(initialLayer);
});
test('should navigate to specific date and display points layer', async ({ page }) => {
// Wait for map to be ready
await page.waitForFunction(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container && container._leaflet_id !== undefined;
}, { timeout: 10000 });
// Navigate to date 13.10.2024
// First, need to expand the date controls on mobile (if collapsed)
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
// Clear and fill in the start date/time input (midnight)
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill('2024-10-13T00:00');
// Clear and fill in the end date/time input (end of day)
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill('2024-10-13T23:59');
// Click the Search button to submit
await page.click('input[type="submit"][value="Search"]');
// Wait for page navigation and map reload
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000); // Wait for map to reinitialize
// Close onboarding modal if it appears after navigation
await closeOnboardingModal(page);
// Open layer control to enable points
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
// Enable points layer if not already enabled
const pointsCheckbox = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]').first();
const isChecked = await pointsCheckbox.isChecked();
if (!isChecked) {
await pointsCheckbox.check();
await page.waitForTimeout(1000); // Wait for points to render
}
// Verify points are visible on the map
const layerInfo = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (!controller) {
return { error: 'Controller not found' };
}
const result = {
hasMarkersLayer: !!controller.markersLayer,
markersCount: 0,
hasPolylinesLayer: !!controller.polylinesLayer,
polylinesCount: 0,
hasTracksLayer: !!controller.tracksLayer,
tracksCount: 0,
};
// Check markers layer
if (controller.markersLayer && controller.markersLayer._layers) {
result.markersCount = Object.keys(controller.markersLayer._layers).length;
}
// Check polylines layer
if (controller.polylinesLayer && controller.polylinesLayer._layers) {
result.polylinesCount = Object.keys(controller.polylinesLayer._layers).length;
}
// Check tracks layer
if (controller.tracksLayer && controller.tracksLayer._layers) {
result.tracksCount = Object.keys(controller.tracksLayer._layers).length;
}
return result;
});
// Verify that at least one layer has data
const hasData = layerInfo.markersCount > 0 ||
layerInfo.polylinesCount > 0 ||
layerInfo.tracksCount > 0;
expect(hasData).toBe(true);
});
});

184
e2e/map/map-layers.spec.js Normal file
View file

@ -0,0 +1,184 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer } from '../helpers/map.js';
test.describe('Map Layers', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
});
test('should enable Routes layer and display routes', async ({ page }) => {
// Wait for map to be ready
await page.waitForFunction(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container && container._leaflet_id !== undefined;
}, { timeout: 10000 });
// Navigate to date with data
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill('2024-10-13T00:00');
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill('2024-10-13T23:59');
await page.click('input[type="submit"][value="Search"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Close onboarding modal if present
await closeOnboardingModal(page);
// Open layer control and enable Routes
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
const isChecked = await routesCheckbox.isChecked();
if (!isChecked) {
await routesCheckbox.check();
await page.waitForTimeout(1000);
}
// Verify routes are visible
const hasRoutes = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.polylinesLayer && controller.polylinesLayer._layers) {
return Object.keys(controller.polylinesLayer._layers).length > 0;
}
return false;
});
expect(hasRoutes).toBe(true);
});
test('should enable Heatmap layer and display heatmap', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Heatmap');
const hasHeatmap = await page.locator('.leaflet-heatmap-layer').isVisible();
expect(hasHeatmap).toBe(true);
});
test('should enable Fog of War layer and display fog', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Fog of War');
const hasFog = await page.evaluate(() => {
const fogCanvas = document.getElementById('fog');
return fogCanvas && fogCanvas instanceof HTMLCanvasElement;
});
expect(hasFog).toBe(true);
});
test('should enable Areas layer and display areas', async ({ page }) => {
await waitForMap(page);
const hasAreasLayer = await page.evaluate(() => {
const mapElement = document.querySelector('#map');
const app = window.Stimulus;
const controller = app?.getControllerForElementAndIdentifier(mapElement, 'maps');
return controller?.areasLayer !== null && controller?.areasLayer !== undefined;
});
expect(hasAreasLayer).toBe(true);
});
test('should enable Suggested Visits layer', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Suggested Visits');
const hasSuggestedVisits = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.visitCircles !== null &&
controller?.visitsManager?.visitCircles !== undefined;
});
expect(hasSuggestedVisits).toBe(true);
});
test('should enable Confirmed Visits layer', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Confirmed Visits');
const hasConfirmedVisits = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.confirmedVisitCircles !== null &&
controller?.visitsManager?.confirmedVisitCircles !== undefined;
});
expect(hasConfirmedVisits).toBe(true);
});
test('should enable Scratch Map layer and display visited countries', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Scratch Map');
// Wait a bit for the layer to load country borders
await page.waitForTimeout(2000);
// Verify scratch layer exists and has been initialized
const hasScratchLayer = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
// Check if scratchLayerManager exists
if (!controller?.scratchLayerManager) return false;
// Check if scratch layer was created
const scratchLayer = controller.scratchLayerManager.getLayer();
return scratchLayer !== null && scratchLayer !== undefined;
});
expect(hasScratchLayer).toBe(true);
});
test('should remember enabled layers across page reloads', async ({ page }) => {
await waitForMap(page);
// Enable multiple layers
await enableLayer(page, 'Points');
await enableLayer(page, 'Routes');
await enableLayer(page, 'Heatmap');
await page.waitForTimeout(500);
// Get current layer states
const getLayerStates = () => page.evaluate(() => {
const layers = {};
document.querySelectorAll('.leaflet-control-layers-overlays input[type="checkbox"]').forEach(checkbox => {
const label = checkbox.parentElement.textContent.trim();
layers[label] = checkbox.checked;
});
return layers;
});
const layersBeforeReload = await getLayerStates();
// Reload the page
await page.reload();
await closeOnboardingModal(page);
await waitForMap(page);
await page.waitForTimeout(1000); // Wait for layers to restore
// Get layer states after reload
const layersAfterReload = await getLayerStates();
// Verify Points, Routes, and Heatmap are still enabled
expect(layersAfterReload['Points']).toBe(true);
expect(layersAfterReload['Routes']).toBe(true);
expect(layersAfterReload['Heatmap']).toBe(true);
// Verify layer states match before and after
expect(layersAfterReload).toEqual(layersBeforeReload);
});
});

141
e2e/map/map-points.spec.js Normal file
View file

@ -0,0 +1,141 @@
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap, enableLayer } from '../helpers/map.js';
test.describe('Point Interactions', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
await enableLayer(page, 'Points');
await page.waitForTimeout(1500);
// Pan map to ensure a marker is in viewport
await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.markers && controller.markers.length > 0) {
const firstMarker = controller.markers[0];
controller.map.setView([firstMarker[0], firstMarker[1]], 14);
}
});
await page.waitForTimeout(1000);
});
test('should have draggable markers on the map', async ({ page }) => {
// Verify markers have draggable class
const marker = page.locator('.leaflet-marker-icon').first();
await expect(marker).toBeVisible();
// Check if marker has draggable class
const isDraggable = await marker.evaluate((el) => {
return el.classList.contains('leaflet-marker-draggable');
});
expect(isDraggable).toBe(true);
// Verify marker position can be retrieved (required for drag operations)
const box = await marker.boundingBox();
expect(box).not.toBeNull();
expect(box.x).toBeGreaterThan(0);
expect(box.y).toBeGreaterThan(0);
});
test('should open popup when clicking a point', async ({ page }) => {
// Click on a marker with force to ensure interaction
const marker = page.locator('.leaflet-marker-icon').first();
await marker.click({ force: true });
await page.waitForTimeout(500);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible();
});
test('should display correct popup content with point data', async ({ page }) => {
// Click on a marker
const marker = page.locator('.leaflet-marker-icon').first();
await marker.click({ force: true });
await page.waitForTimeout(500);
// Get popup content
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent).toBeVisible();
const content = await popupContent.textContent();
// Verify all required fields are present
expect(content).toContain('Timestamp:');
expect(content).toContain('Latitude:');
expect(content).toContain('Longitude:');
expect(content).toContain('Altitude:');
expect(content).toContain('Speed:');
expect(content).toContain('Battery:');
expect(content).toContain('Id:');
});
test('should delete a point and redraw route', async ({ page }) => {
// Enable Routes layer to verify route redraw
await enableLayer(page, 'Routes');
await page.waitForTimeout(1000);
// Count initial markers and get point ID
const initialData = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0;
const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0;
return { markerCount, polylineCount };
});
// Click on a marker to open popup
const marker = page.locator('.leaflet-marker-icon').first();
await marker.click({ force: true });
await page.waitForTimeout(500);
// Verify popup opened
await expect(page.locator('.leaflet-popup')).toBeVisible();
// Get the point ID from popup before deleting
const pointId = await page.locator('.leaflet-popup-content').evaluate((content) => {
const match = content.textContent.match(/Id:\s*(\d+)/);
return match ? match[1] : null;
});
expect(pointId).not.toBeNull();
// Find delete button (might be a link or button with "Delete" text)
const deleteButton = page.locator('.leaflet-popup-content a:has-text("Delete"), .leaflet-popup-content button:has-text("Delete")').first();
const hasDeleteButton = await deleteButton.count() > 0;
if (hasDeleteButton) {
// Handle confirmation dialog
page.once('dialog', dialog => {
expect(dialog.message()).toContain('delete');
dialog.accept();
});
await deleteButton.click();
await page.waitForTimeout(2000); // Wait for deletion to complete
// Verify marker count decreased
const finalData = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0;
const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0;
return { markerCount, polylineCount };
});
// Verify at least one marker was removed
expect(finalData.markerCount).toBeLessThan(initialData.markerCount);
// Verify routes still exist (they should be redrawn)
expect(finalData.polylineCount).toBeGreaterThanOrEqual(0);
// Verify success flash message appears
const flashMessage = page.locator('#flash-messages [role="alert"]').filter({ hasText: /deleted successfully/i });
await expect(flashMessage).toBeVisible({ timeout: 5000 });
} else {
// If no delete button, just verify the test setup worked
console.log('No delete button found in popup - this might be expected based on permissions');
}
});
});

View file

@ -0,0 +1,166 @@
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap } from '../helpers/map.js';
test.describe('Selection Tool', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
});
test('should enable selection mode when clicked', async ({ page }) => {
// Click selection tool button
const selectionButton = page.locator('#selection-tool-button');
await expect(selectionButton).toBeVisible();
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is enabled (flash message appears)
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Selection mode enabled")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Verify selection mode is active in controller
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === true;
});
expect(isSelectionActive).toBe(true);
// Verify button has active class
const hasActiveClass = await selectionButton.evaluate((el) => {
return el.classList.contains('active');
});
expect(hasActiveClass).toBe(true);
// Verify map dragging is disabled (required for selection to work)
const isDraggingDisabled = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return !controller?.map?.dragging?.enabled();
});
expect(isDraggingDisabled).toBe(true);
});
test('should disable selection mode when clicked second time', async ({ page }) => {
const selectionButton = page.locator('#selection-tool-button');
// First click - enable selection mode
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is enabled
const isEnabledAfterFirstClick = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === true;
});
expect(isEnabledAfterFirstClick).toBe(true);
// Second click - disable selection mode
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is disabled
const isDisabledAfterSecondClick = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === false;
});
expect(isDisabledAfterSecondClick).toBe(true);
// Verify no selection rectangle exists
const hasSelectionRect = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.selectionRect !== null;
});
expect(hasSelectionRect).toBe(false);
// Verify button no longer has active class
const hasActiveClass = await selectionButton.evaluate((el) => {
return el.classList.contains('active');
});
expect(hasActiveClass).toBe(false);
// Verify map dragging is re-enabled
const isDraggingEnabled = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.dragging?.enabled();
});
expect(isDraggingEnabled).toBe(true);
});
test('should show info message about dragging to select area', async ({ page }) => {
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Verify informational flash message about dragging
const flashMessage = page.locator('#flash-messages [role="alert"]');
const messageText = await flashMessage.textContent();
expect(messageText).toContain('Click and drag');
});
test('should open side panel when selection is complete', async ({ page }) => {
// Navigate to a date with known data (October 13, 2024 - same as bulk delete tests)
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill('2024-10-13T00:00');
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill('2024-10-13T23:59');
await page.click('input[type="submit"][value="Search"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify drawer is initially closed
const drawerInitiallyClosed = await page.evaluate(() => {
const drawer = document.getElementById('visits-drawer');
return !drawer?.classList.contains('open');
});
expect(drawerInitiallyClosed).toBe(true);
// Enable selection mode
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Draw a selection rectangle on the map
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Draw rectangle covering most of the map to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
// Wait for drawer to open
await page.waitForTimeout(2000);
// Verify drawer is now open
const drawerOpen = await page.evaluate(() => {
const drawer = document.getElementById('visits-drawer');
return drawer?.classList.contains('open');
});
expect(drawerOpen).toBe(true);
// Verify drawer shows either selection data or cancel button (indicates selection is active)
const hasCancelButton = await page.locator('#cancel-selection-button').isVisible();
expect(hasCancelButton).toBe(true);
});
});

View file

@ -0,0 +1,644 @@
import { test, expect } from '@playwright/test';
import { closeOnboardingModal, navigateToDate } from '../helpers/navigation.js';
import { drawSelectionRectangle } from '../helpers/selection.js';
/**
* Side Panel (Visits Drawer) Tests
*
* Tests for the side panel that displays visits when selection tool is used.
* The panel can be toggled via the drawer button and shows suggested/confirmed visits
* with options to confirm, decline, or merge them.
*/
test.describe('Side Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/map');
await closeOnboardingModal(page);
// Wait for map to be fully loaded
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
await page.waitForTimeout(2000);
// Navigate to October 2024 (has demo data)
await navigateToDate(page, '2024-10-01T00:00', '2024-10-31T23:59');
await page.waitForTimeout(2000);
});
/**
* Helper function to click the drawer button
*/
async function clickDrawerButton(page) {
const drawerButton = page.locator('.drawer-button');
await expect(drawerButton).toBeVisible({ timeout: 5000 });
await drawerButton.click();
await page.waitForTimeout(500); // Wait for drawer animation
}
/**
* Helper function to check if drawer is open
*/
async function isDrawerOpen(page) {
const drawer = page.locator('#visits-drawer');
const exists = await drawer.count() > 0;
if (!exists) return false;
const hasOpenClass = await drawer.evaluate(el => el.classList.contains('open'));
return hasOpenClass;
}
/**
* Helper function to perform selection and wait for visits to load
* This is a simplified version that doesn't use the shared helper
* because we need custom waiting logic for the drawer
*/
async function selectAreaWithVisits(page) {
// First, enable Suggested Visits layer to ensure visits are loaded
const layersButton = page.locator('.leaflet-control-layers-toggle');
await layersButton.click();
await page.waitForTimeout(500);
// Enable "Suggested Visits" layer
const suggestedVisitsCheckbox = page.locator('input[type="checkbox"]').filter({
has: page.locator(':scope ~ span', { hasText: 'Suggested Visits' })
});
const isChecked = await suggestedVisitsCheckbox.isChecked();
if (!isChecked) {
await suggestedVisitsCheckbox.check();
await page.waitForTimeout(1000);
}
// Close layers control
await layersButton.click();
await page.waitForTimeout(500);
// Enable selection mode
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Get map bounds for drawing selection
const map = page.locator('.leaflet-container');
const mapBox = await map.boundingBox();
// Calculate coordinates for drawing a large selection area
// Make it much wider to catch visits - use most of the map area
const startX = mapBox.x + 100;
const startY = mapBox.y + 100;
const endX = mapBox.x + mapBox.width - 400; // Leave room for drawer on right
const endY = mapBox.y + mapBox.height - 100;
// Draw selection rectangle
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
// Wait for drawer to be created and opened
await page.waitForSelector('#visits-drawer.open', { timeout: 10000 });
await page.waitForTimeout(3000); // Wait longer for visits API response
}
test('should open and close drawer panel via button click', async ({ page }) => {
// Verify drawer is initially closed
const initiallyOpen = await isDrawerOpen(page);
expect(initiallyOpen).toBe(false);
// Click to open
await clickDrawerButton(page);
// Verify drawer is now open
let drawerOpen = await isDrawerOpen(page);
expect(drawerOpen).toBe(true);
// Verify drawer content is visible
const drawerContent = page.locator('#visits-drawer .drawer');
await expect(drawerContent).toBeVisible();
// Click to close
await clickDrawerButton(page);
// Verify drawer is now closed
drawerOpen = await isDrawerOpen(page);
expect(drawerOpen).toBe(false);
});
test('should show visits in panel after selection', async ({ page }) => {
await selectAreaWithVisits(page);
// Verify drawer is open
const drawerOpen = await isDrawerOpen(page);
expect(drawerOpen).toBe(true);
// Verify visits list container exists
const visitsList = page.locator('#visits-list');
await expect(visitsList).toBeVisible();
// Wait for API response - check if we have visit items or "no visits" message
await page.waitForTimeout(2000);
// Check what content is actually shown
const visitItems = page.locator('.visit-item');
const visitCount = await visitItems.count();
const noVisitsMessage = page.locator('#visits-list p.text-gray-500');
// Either we have visits OR we have a "no visits" message (not "Loading...")
if (visitCount > 0) {
// We have visits - verify the title shows count
const drawerTitle = page.locator('#visits-drawer .drawer h2');
const titleText = await drawerTitle.textContent();
expect(titleText).toMatch(/\d+ visits? found/);
} else {
// No visits found - verify we show the appropriate message
// Should NOT still be showing "Loading visits..."
const messageText = await noVisitsMessage.textContent();
expect(messageText).not.toContain('Loading visits');
expect(messageText).toContain('No visits');
}
});
test('should display visit details in panel', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
// Check if we have any visits
const visitCount = await page.locator('.visit-item').count();
if (visitCount === 0) {
console.log('Test skipped: No visits available in test data');
test.skip();
return;
}
// Get first visit item
const firstVisit = page.locator('.visit-item').first();
await expect(firstVisit).toBeVisible();
// Verify visit has required information
const visitName = firstVisit.locator('.font-semibold');
await expect(visitName).toBeVisible();
const nameText = await visitName.textContent();
expect(nameText.length).toBeGreaterThan(0);
// Verify time information is present
const timeInfo = firstVisit.locator('.text-sm.text-gray-600');
await expect(timeInfo).toBeVisible();
// Check if this is a suggested visit (has confirm/decline buttons)
const hasSuggestedButtons = (await firstVisit.locator('.confirm-visit').count()) > 0;
if (hasSuggestedButtons) {
// For suggested visits, verify action buttons are present
const confirmButton = firstVisit.locator('.confirm-visit');
const declineButton = firstVisit.locator('.decline-visit');
await expect(confirmButton).toBeVisible();
await expect(declineButton).toBeVisible();
expect(await confirmButton.textContent()).toBe('Confirm');
expect(await declineButton.textContent()).toBe('Decline');
}
});
test('should confirm individual suggested visit from panel', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
// Find a suggested visit (one with confirm/decline buttons)
const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).first();
// Check if any suggested visits exist
const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).count();
if (suggestedCount === 0) {
console.log('Test skipped: No suggested visits available');
test.skip();
return;
}
await expect(suggestedVisit).toBeVisible();
// Verify it has the suggested visit styling (dashed border)
const hasDashedBorder = await suggestedVisit.evaluate(el =>
el.classList.contains('border-dashed')
);
expect(hasDashedBorder).toBe(true);
// Get initial count of visits
const initialVisitCount = await page.locator('.visit-item').count();
// Click confirm button
const confirmButton = suggestedVisit.locator('.confirm-visit');
await confirmButton.click();
// Wait for API call and UI update
await page.waitForTimeout(2000);
// Verify flash message appears
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// The visit should still be in the list but without confirm/decline buttons
// Or the count might decrease if it was removed from suggested visits
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThanOrEqual(initialVisitCount);
});
test('should decline individual suggested visit from panel', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
// Find a suggested visit
const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).first();
const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).count();
if (suggestedCount === 0) {
console.log('Test skipped: No suggested visits available');
test.skip();
return;
}
await expect(suggestedVisit).toBeVisible();
// Get initial count
const initialVisitCount = await page.locator('.visit-item').count();
// Click decline button
const declineButton = suggestedVisit.locator('.decline-visit');
await declineButton.click();
// Wait for API call and UI update
await page.waitForTimeout(2000);
// Verify flash message
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Visit should be removed from the list
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
test('should show checkboxes on hover for mass selection', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
// Check if we have any visits
const visitCount = await page.locator('.visit-item').count();
if (visitCount === 0) {
console.log('Test skipped: No visits available in test data');
test.skip();
return;
}
const firstVisit = page.locator('.visit-item').first();
await expect(firstVisit).toBeVisible();
// Initially, checkbox should be hidden
const checkboxContainer = firstVisit.locator('.visit-checkbox-container');
let opacity = await checkboxContainer.evaluate(el => el.style.opacity);
expect(opacity === '0' || opacity === '').toBe(true);
// Hover over the visit item
await firstVisit.hover();
await page.waitForTimeout(300);
// Checkbox should now be visible
opacity = await checkboxContainer.evaluate(el => el.style.opacity);
expect(opacity).toBe('1');
// Checkbox should be clickable
const pointerEvents = await checkboxContainer.evaluate(el => el.style.pointerEvents);
expect(pointerEvents).toBe('auto');
});
test('should select multiple visits and show bulk action buttons', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
// Verify we have at least 2 visits
const visitCount = await page.locator('.visit-item').count();
if (visitCount < 2) {
console.log('Test skipped: Need at least 2 visits');
test.skip();
return;
}
// Select first visit by hovering and clicking checkbox
const firstVisit = page.locator('.visit-item').first();
await firstVisit.hover();
await page.waitForTimeout(300);
const firstCheckbox = firstVisit.locator('.visit-checkbox');
await firstCheckbox.click();
await page.waitForTimeout(500);
// Select second visit
const secondVisit = page.locator('.visit-item').nth(1);
await secondVisit.hover();
await page.waitForTimeout(300);
const secondCheckbox = secondVisit.locator('.visit-checkbox');
await secondCheckbox.click();
await page.waitForTimeout(500);
// Verify bulk action buttons appear
const bulkActionsContainer = page.locator('.visit-bulk-actions');
await expect(bulkActionsContainer).toBeVisible();
// Verify all three action buttons are present
const mergeButton = bulkActionsContainer.locator('button').filter({ hasText: 'Merge' });
const confirmButton = bulkActionsContainer.locator('button').filter({ hasText: 'Confirm' });
const declineButton = bulkActionsContainer.locator('button').filter({ hasText: 'Decline' });
await expect(mergeButton).toBeVisible();
await expect(confirmButton).toBeVisible();
await expect(declineButton).toBeVisible();
// Verify selection count text
const selectionText = bulkActionsContainer.locator('.text-sm.text-center');
const selectionTextContent = await selectionText.textContent();
expect(selectionTextContent).toContain('2 visits selected');
// Verify cancel button exists
const cancelButton = bulkActionsContainer.locator('button').filter({ hasText: 'Cancel Selection' });
await expect(cancelButton).toBeVisible();
});
test('should cancel mass selection', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
const visitCount = await page.locator('.visit-item').count();
if (visitCount < 2) {
console.log('Test skipped: Need at least 2 visits');
test.skip();
return;
}
// Select two visits
const firstVisit = page.locator('.visit-item').first();
await firstVisit.hover();
await page.waitForTimeout(300);
await firstVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondVisit = page.locator('.visit-item').nth(1);
await secondVisit.hover();
await page.waitForTimeout(300);
await secondVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Verify bulk actions are visible
const bulkActions = page.locator('.visit-bulk-actions');
await expect(bulkActions).toBeVisible();
// Click cancel button
const cancelButton = bulkActions.locator('button').filter({ hasText: 'Cancel Selection' });
await cancelButton.click();
await page.waitForTimeout(500);
// Verify bulk actions are removed
await expect(bulkActions).not.toBeVisible();
// Verify checkboxes are unchecked
const checkedCheckboxes = await page.locator('.visit-checkbox:checked').count();
expect(checkedCheckboxes).toBe(0);
});
test('should mass confirm multiple visits', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
// Find suggested visits (those with confirm buttons)
const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') });
const suggestedCount = await suggestedVisits.count();
if (suggestedCount < 2) {
console.log('Test skipped: Need at least 2 suggested visits');
test.skip();
return;
}
// Get initial count
const initialVisitCount = await page.locator('.visit-item').count();
// Select first two suggested visits
const firstSuggested = suggestedVisits.first();
await firstSuggested.hover();
await page.waitForTimeout(300);
await firstSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondSuggested = suggestedVisits.nth(1);
await secondSuggested.hover();
await page.waitForTimeout(300);
await secondSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Click mass confirm button
const bulkActions = page.locator('.visit-bulk-actions');
const confirmButton = bulkActions.locator('button').filter({ hasText: 'Confirm' });
await confirmButton.click();
// Wait for API call
await page.waitForTimeout(2000);
// Verify flash message
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// The visits might be removed or updated in the list
// At minimum, bulk actions should be removed
const bulkActionsVisible = await bulkActions.isVisible().catch(() => false);
expect(bulkActionsVisible).toBe(false);
});
test('should mass decline multiple visits', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') });
const suggestedCount = await suggestedVisits.count();
if (suggestedCount < 2) {
console.log('Test skipped: Need at least 2 suggested visits');
test.skip();
return;
}
// Get initial count
const initialVisitCount = await page.locator('.visit-item').count();
// Select two visits
const firstSuggested = suggestedVisits.first();
await firstSuggested.hover();
await page.waitForTimeout(300);
await firstSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondSuggested = suggestedVisits.nth(1);
await secondSuggested.hover();
await page.waitForTimeout(300);
await secondSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Click mass decline button
const bulkActions = page.locator('.visit-bulk-actions');
const declineButton = bulkActions.locator('button').filter({ hasText: 'Decline' });
await declineButton.click();
// Wait for API call
await page.waitForTimeout(2000);
// Verify flash message
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Visits should be removed from the list
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
test('should mass merge multiple visits', async ({ page }) => {
await selectAreaWithVisits(page);
// Open the visits collapsible section
const visitsSection = page.locator('#visits-section-collapse');
await expect(visitsSection).toBeVisible();
const visitsSummary = visitsSection.locator('summary');
await visitsSummary.click();
await page.waitForTimeout(500);
const visitCount = await page.locator('.visit-item').count();
if (visitCount < 2) {
console.log('Test skipped: Need at least 2 visits');
test.skip();
return;
}
// Select two visits
const firstVisit = page.locator('.visit-item').first();
await firstVisit.hover();
await page.waitForTimeout(300);
await firstVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondVisit = page.locator('.visit-item').nth(1);
await secondVisit.hover();
await page.waitForTimeout(300);
await secondVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Click merge button
const bulkActions = page.locator('.visit-bulk-actions');
const mergeButton = bulkActions.locator('button').filter({ hasText: 'Merge' });
await mergeButton.click();
// Wait for API call
await page.waitForTimeout(2000);
// Verify flash message appears
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// After merge, the visits should be combined into one
// So final count should be less than initial
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThan(visitCount);
});
test('should open and close panel without shifting controls', async ({ page }) => {
// Get the layer control element
const layerControl = page.locator('.leaflet-control-layers');
await expect(layerControl).toBeVisible();
// Get initial position of the control
const initialBox = await layerControl.boundingBox();
// Open the drawer
await clickDrawerButton(page);
await page.waitForTimeout(500);
// Verify drawer is open
const drawerOpen = await isDrawerOpen(page);
expect(drawerOpen).toBe(true);
// Get position after opening - should be the same (no shifting)
const afterOpenBox = await layerControl.boundingBox();
expect(afterOpenBox.x).toBe(initialBox.x);
expect(afterOpenBox.y).toBe(initialBox.y);
// Close the drawer
await clickDrawerButton(page);
await page.waitForTimeout(500);
// Verify drawer is closed
const drawerClosed = await isDrawerOpen(page);
expect(drawerClosed).toBe(false);
// Get final position - should still be the same
const afterCloseBox = await layerControl.boundingBox();
expect(afterCloseBox.x).toBe(initialBox.x);
expect(afterCloseBox.y).toBe(initialBox.y);
});
});

View file

@ -0,0 +1,296 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer, clickSuggestedVisit } from '../helpers/map.js';
test.describe('Suggested Visit Interactions', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
// Navigate to a date range that includes visits (last month to now)
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
// Set date range to last month
await page.click('a:has-text("Last month")');
await page.waitForTimeout(2000);
await closeOnboardingModal(page);
await waitForMap(page);
await enableLayer(page, 'Suggested Visits');
await page.waitForTimeout(2000);
// Pan map to ensure a visit marker is in viewport
await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles) {
const layers = controller.visitsManager.suggestedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit && firstVisit._latlng) {
controller.map.setView(firstVisit._latlng, 14);
}
}
});
await page.waitForTimeout(1000);
});
test('should click on a suggested visit and open popup', async ({ page }) => {
// Debug: Check what visit circles exist
const allCircles = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
const layers = controller.visitsManager.suggestedVisitCircles._layers;
return {
count: Object.keys(layers).length,
hasLayers: Object.keys(layers).length > 0
};
}
return { count: 0, hasLayers: false };
});
// If we have visits in the layer but can't find DOM elements, use coordinates
if (!allCircles.hasLayers) {
console.log('No suggested visits found - skipping test');
return;
}
// Click on the visit using map coordinates
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('Could not click suggested visit - skipping test');
return;
}
await page.waitForTimeout(500);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible();
});
test('should display correct content in suggested visit popup', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('No suggested visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Get popup content
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent).toBeVisible();
const content = await popupContent.textContent();
// Verify visit information is present
expect(content).toMatch(/Visit|Place|Duration|Started|Ended|Suggested/i);
});
test('should confirm suggested visit', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('No suggested visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Look for confirm button in popup
const confirmButton = page.locator('.leaflet-popup-content button:has-text("Confirm")').first();
const hasConfirmButton = await confirmButton.count() > 0;
if (!hasConfirmButton) {
console.log('No confirm button found - skipping test');
return;
}
// Get initial counts for both suggested and confirmed visits
const initialCounts = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return {
suggested: controller?.visitsManager?.suggestedVisitCircles?._layers
? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length
: 0,
confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers
? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length
: 0
};
});
// Click confirm button
await confirmButton.click();
await page.waitForTimeout(1500);
// Verify the marker changed from yellow to green (suggested to confirmed)
const finalCounts = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return {
suggested: controller?.visitsManager?.suggestedVisitCircles?._layers
? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length
: 0,
confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers
? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length
: 0
};
});
// Verify suggested visit count decreased
expect(finalCounts.suggested).toBeLessThan(initialCounts.suggested);
// Verify confirmed visit count increased (marker changed from yellow to green)
expect(finalCounts.confirmed).toBeGreaterThan(initialCounts.confirmed);
// Verify popup is closed after confirmation
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
});
test('should decline suggested visit', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('No suggested visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Look for decline button in popup
const declineButton = page.locator('.leaflet-popup-content button:has-text("Decline")').first();
const hasDeclineButton = await declineButton.count() > 0;
if (!hasDeclineButton) {
console.log('No decline button found - skipping test');
return;
}
// Get initial suggested visit count
const initialCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
// Verify popup is visible before decline
await expect(page.locator('.leaflet-popup')).toBeVisible();
// Click decline button
await declineButton.click();
await page.waitForTimeout(1500);
// Verify popup is removed from map
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify marker is removed from map (suggested visit count decreased)
const finalCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
expect(finalCount).toBeLessThan(initialCount);
// Verify the yellow marker is no longer visible on the map
const yellowMarkerCount = await page.locator('.leaflet-interactive[stroke="#f59e0b"]').count();
expect(yellowMarkerCount).toBeLessThan(initialCount);
});
test('should change place in dropdown for suggested visit', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No suggested visits found - skipping test');
return;
}
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Look for place dropdown/select in popup
const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first();
const hasPlaceDropdown = await placeSelect.count() > 0;
if (!hasPlaceDropdown) {
console.log('No place dropdown found - skipping test');
return;
}
// Select a different option
await placeSelect.selectOption({ index: 1 });
await page.waitForTimeout(300);
// Verify the selection changed
const newValue = await placeSelect.inputValue();
expect(newValue).toBeTruthy();
});
test('should delete suggested visit from map', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No suggested visits found - skipping test');
return;
}
// Count initial visits
const initialVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Find delete button
const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first();
const hasDeleteButton = await deleteButton.count() > 0;
if (!hasDeleteButton) {
console.log('No delete button found - skipping test');
return;
}
// Handle confirmation dialog
page.once('dialog', dialog => {
expect(dialog.message()).toMatch(/delete|remove/i);
dialog.accept();
});
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify visit count decreased
const finalVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
});

243
e2e/map/map-visits.spec.js Normal file
View file

@ -0,0 +1,243 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer, clickConfirmedVisit } from '../helpers/map.js';
test.describe('Visit Interactions', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
// Navigate to a date range that includes visits (last month to now)
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
// Set date range to last month
await page.click('a:has-text("Last month")');
await page.waitForTimeout(2000);
await closeOnboardingModal(page);
await waitForMap(page);
await enableLayer(page, 'Confirmed Visits');
await page.waitForTimeout(2000);
// Pan map to ensure a visit marker is in viewport
await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles) {
const layers = controller.visitsManager.confirmedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit && firstVisit._latlng) {
controller.map.setView(firstVisit._latlng, 14);
}
}
});
await page.waitForTimeout(1000);
});
test('should click on a confirmed visit and open popup', async ({ page }) => {
// Debug: Check what visit circles exist
const allCircles = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
const layers = controller.visitsManager.confirmedVisitCircles._layers;
return {
count: Object.keys(layers).length,
hasLayers: Object.keys(layers).length > 0
};
}
return { count: 0, hasLayers: false };
});
// If we have visits in the layer but can't find DOM elements, use coordinates
if (!allCircles.hasLayers) {
console.log('No confirmed visits found - skipping test');
return;
}
// Click on the visit using map coordinates
const visitClicked = await clickConfirmedVisit(page);
if (!visitClicked) {
console.log('Could not click visit - skipping test');
return;
}
await page.waitForTimeout(500);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible();
});
test('should display correct content in confirmed visit popup', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickConfirmedVisit(page);
if (!visitClicked) {
console.log('No confirmed visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Get popup content
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent).toBeVisible();
const content = await popupContent.textContent();
// Verify visit information is present
expect(content).toMatch(/Visit|Place|Duration|Started|Ended/i);
});
test('should change place in dropdown and save', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No confirmed visits found - skipping test');
return;
}
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Look for place dropdown/select in popup
const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first();
const hasPlaceDropdown = await placeSelect.count() > 0;
if (!hasPlaceDropdown) {
console.log('No place dropdown found - skipping test');
return;
}
// Get current value
const initialValue = await placeSelect.inputValue().catch(() => null);
// Select a different option
await placeSelect.selectOption({ index: 1 });
await page.waitForTimeout(300);
// Find and click save button
const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first();
const hasSaveButton = await saveButton.count() > 0;
if (hasSaveButton) {
await saveButton.click();
await page.waitForTimeout(1000);
// Verify popup closes after successful save
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify success flash message appears
const flashMessage = page.locator('#flash-messages [role="alert"]');
await expect(flashMessage).toBeVisible({ timeout: 2000 });
const messageText = await flashMessage.textContent();
expect(messageText).toContain('Visit updated successfully');
}
});
test('should change visit name and save', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No confirmed visits found - skipping test');
return;
}
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Look for name input field
const nameInput = page.locator('.leaflet-popup-content input[type="text"]').first();
const hasNameInput = await nameInput.count() > 0;
if (!hasNameInput) {
console.log('No name input found - skipping test');
return;
}
// Change the name
const newName = `Test Visit ${Date.now()}`;
await nameInput.fill(newName);
await page.waitForTimeout(300);
// Find and click save button
const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first();
const hasSaveButton = await saveButton.count() > 0;
if (hasSaveButton) {
await saveButton.click();
await page.waitForTimeout(1000);
// Verify popup closes after successful save
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify success flash message appears
const flashMessage = page.locator('#flash-messages [role="alert"]');
await expect(flashMessage).toBeVisible({ timeout: 2000 });
const messageText = await flashMessage.textContent();
expect(messageText).toContain('Visit updated successfully');
}
});
test('should delete confirmed visit from map', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No confirmed visits found - skipping test');
return;
}
// Count initial visits
const initialVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Find delete button
const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first();
const hasDeleteButton = await deleteButton.count() > 0;
if (!hasDeleteButton) {
console.log('No delete button found - skipping test');
return;
}
// Handle confirmation dialog
page.once('dialog', dialog => {
expect(dialog.message()).toMatch(/delete|remove/i);
dialog.accept();
});
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify visit count decreased
const finalVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
});

View file

@ -1,180 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Test to verify the marker factory refactoring is memory-safe
* and maintains consistent marker creation across different use cases
*/
test.describe('Marker Factory Refactoring', () => {
let page;
let context;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
// Sign in
await page.goto('/users/sign_in');
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
await page.click('input[type="submit"][value="Log in"]');
await page.waitForURL('/map', { timeout: 10000 });
});
test.afterAll(async () => {
await page.close();
await context.close();
});
test('should have marker factory available in bundled code', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Check if marker factory functions are available in the bundled code
const factoryAnalysis = await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
const allJavaScript = scripts.join(' ');
return {
hasMarkerFactory: allJavaScript.includes('marker_factory') || allJavaScript.includes('MarkerFactory'),
hasCreateLiveMarker: allJavaScript.includes('createLiveMarker'),
hasCreateInteractiveMarker: allJavaScript.includes('createInteractiveMarker'),
hasCreateStandardIcon: allJavaScript.includes('createStandardIcon'),
totalJSSize: allJavaScript.length,
scriptCount: scripts.length
};
});
console.log('Marker factory analysis:', factoryAnalysis);
// The refactoring should be present (though may not be detectable in bundled JS)
expect(factoryAnalysis.scriptCount).toBeGreaterThan(0);
expect(factoryAnalysis.totalJSSize).toBeGreaterThan(1000);
});
test('should maintain consistent marker styling across use cases', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Check for consistent marker styling in the DOM
const markerConsistency = await page.evaluate(() => {
// Look for custom-div-icon markers (our standard marker style)
const customMarkers = document.querySelectorAll('.custom-div-icon');
const markerStyles = Array.from(customMarkers).map(marker => {
const innerDiv = marker.querySelector('div');
return {
hasInnerDiv: !!innerDiv,
backgroundColor: innerDiv?.style.backgroundColor || 'none',
borderRadius: innerDiv?.style.borderRadius || 'none',
width: innerDiv?.style.width || 'none',
height: innerDiv?.style.height || 'none'
};
});
// Check if all markers have consistent styling
const hasConsistentStyling = markerStyles.every(style =>
style.hasInnerDiv &&
style.borderRadius === '50%' &&
(style.backgroundColor === 'blue' || style.backgroundColor === 'orange') &&
style.width === style.height // Should be square
);
return {
totalCustomMarkers: customMarkers.length,
markerStyles: markerStyles.slice(0, 3), // Show first 3 for debugging
hasConsistentStyling,
allMarkersCount: document.querySelectorAll('.leaflet-marker-icon').length
};
});
console.log('Marker consistency analysis:', markerConsistency);
// Verify consistent styling if markers are present
if (markerConsistency.totalCustomMarkers > 0) {
expect(markerConsistency.hasConsistentStyling).toBe(true);
}
// Test always passes as we've verified implementation
expect(true).toBe(true);
});
test('should have memory-safe marker creation patterns', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Monitor basic memory patterns
const memoryInfo = await page.evaluate(() => {
const memory = window.performance.memory;
return {
usedJSHeapSize: memory?.usedJSHeapSize || 0,
totalJSHeapSize: memory?.totalJSHeapSize || 0,
jsHeapSizeLimit: memory?.jsHeapSizeLimit || 0,
memoryAvailable: !!memory
};
});
console.log('Memory info:', memoryInfo);
// Verify memory monitoring is available and reasonable
if (memoryInfo.memoryAvailable) {
expect(memoryInfo.usedJSHeapSize).toBeGreaterThan(0);
expect(memoryInfo.usedJSHeapSize).toBeLessThan(memoryInfo.totalJSHeapSize);
}
// Check for memory-safe patterns in the code structure
const codeSafetyAnalysis = await page.evaluate(() => {
return {
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
hasMapElement: !!document.querySelector('#map'),
leafletLayerCount: document.querySelectorAll('.leaflet-layer').length,
markerPaneElements: document.querySelectorAll('.leaflet-marker-pane').length,
totalLeafletElements: document.querySelectorAll('[class*="leaflet"]').length
};
});
console.log('Code safety analysis:', codeSafetyAnalysis);
// Verify basic structure is sound
expect(codeSafetyAnalysis.hasLeafletContainer).toBe(true);
expect(codeSafetyAnalysis.hasMapElement).toBe(true);
expect(codeSafetyAnalysis.totalLeafletElements).toBeGreaterThan(10);
});
test('should demonstrate marker factory benefits', async () => {
// This test documents the benefits of the marker factory refactoring
console.log('=== MARKER FACTORY REFACTORING BENEFITS ===');
console.log('');
console.log('1. ✅ CODE REUSE:');
console.log(' - Single source of truth for marker styling');
console.log(' - Consistent divIcon creation across all use cases');
console.log(' - Reduced code duplication between markers.js and live_map_handler.js');
console.log('');
console.log('2. ✅ MEMORY SAFETY:');
console.log(' - createLiveMarker(): Lightweight markers for live streaming');
console.log(' - createInteractiveMarker(): Full-featured markers for static display');
console.log(' - createStandardIcon(): Shared icon factory prevents object duplication');
console.log('');
console.log('3. ✅ MAINTENANCE:');
console.log(' - Centralized marker logic in marker_factory.js');
console.log(' - Easy to update styling across entire application');
console.log(' - Clear separation between live and interactive marker features');
console.log('');
console.log('4. ✅ PERFORMANCE:');
console.log(' - Live markers skip expensive drag handlers and popups');
console.log(' - Interactive markers include full feature set only when needed');
console.log(' - No shared object references that could cause memory leaks');
console.log('');
console.log('=== REFACTORING COMPLETE ===');
// Test always passes - this is documentation
expect(true).toBe(true);
});
});

View file

@ -1,140 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Test to verify the Live Mode memory leak fix
* This test focuses on verifying the fix works by checking DOM elements
* and memory patterns rather than requiring full controller integration
*/
test.describe('Memory Leak Fix Verification', () => {
let page;
let context;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
// Sign in
await page.goto('/users/sign_in');
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
await page.click('input[type="submit"][value="Log in"]');
await page.waitForURL('/map', { timeout: 10000 });
});
test.afterAll(async () => {
await page.close();
await context.close();
});
test('should load map page with memory leak fix implemented', async () => {
// Navigate to map with test data
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Verify the updated appendPoint method exists and has the fix
const codeAnalysis = await page.evaluate(() => {
// Check if the maps controller exists and analyze its appendPoint method
const mapElement = document.querySelector('#map');
const controllers = mapElement?._stimulus_controllers;
const mapController = controllers?.find(c => c.identifier === 'maps');
if (mapController && mapController.appendPoint) {
const methodString = mapController.appendPoint.toString();
return {
hasController: true,
hasAppendPoint: true,
// Check for fixed patterns (absence of problematic code)
hasOldClearLayersPattern: methodString.includes('clearLayers()') && methodString.includes('L.layerGroup(this.markersArray)'),
hasOldPolylineRecreation: methodString.includes('createPolylinesLayer'),
// Check for new efficient patterns
hasIncrementalMarkerAdd: methodString.includes('this.markersLayer.addLayer(newMarker)'),
hasBoundedData: methodString.includes('> 1000'),
hasLastMarkerTracking: methodString.includes('this.lastMarkerRef'),
methodLength: methodString.length
};
}
return {
hasController: !!mapController,
hasAppendPoint: false,
controllerCount: controllers?.length || 0
};
});
console.log('Code analysis:', codeAnalysis);
// The test passes if either:
// 1. Controller is found and shows the fix is implemented
// 2. Controller is not found (which is the current issue) but the code exists in the file
if (codeAnalysis.hasController && codeAnalysis.hasAppendPoint) {
// If controller is found, verify the fix
expect(codeAnalysis.hasOldClearLayersPattern).toBe(false); // Old inefficient pattern should be gone
expect(codeAnalysis.hasIncrementalMarkerAdd).toBe(true); // New efficient pattern should exist
expect(codeAnalysis.hasBoundedData).toBe(true); // Should have bounded data structures
} else {
// Controller not found (expected based on previous tests), but we've implemented the fix
console.log('Controller not found in test environment, but fix has been implemented in code');
}
// Verify basic map functionality
const mapState = await page.evaluate(() => {
return {
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length,
hasMapElement: !!document.querySelector('#map'),
mapHasDataController: document.querySelector('#map')?.hasAttribute('data-controller')
};
});
expect(mapState.hasLeafletContainer).toBe(true);
expect(mapState.hasMapElement).toBe(true);
expect(mapState.mapHasDataController).toBe(true);
expect(mapState.leafletElementCount).toBeGreaterThan(10); // Should have substantial Leaflet elements
});
test('should have memory-efficient appendPoint implementation in source code', async () => {
// This test verifies the fix exists in the actual source file
// by checking the current page's loaded JavaScript
const hasEfficientImplementation = await page.evaluate(() => {
// Try to access the source code through various means
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
const allJavaScript = scripts.join(' ');
// Check for key improvements (these should exist in the bundled JS)
const hasIncrementalAdd = allJavaScript.includes('addLayer(newMarker)');
const hasBoundedArrays = allJavaScript.includes('length > 1000');
const hasEfficientTracking = allJavaScript.includes('lastMarkerRef');
// Check that old inefficient patterns are not present together
const hasOldPattern = allJavaScript.includes('clearLayers()') &&
allJavaScript.includes('addLayer(L.layerGroup(this.markersArray))');
return {
hasIncrementalAdd,
hasBoundedArrays,
hasEfficientTracking,
hasOldPattern,
scriptCount: scripts.length,
totalJSSize: allJavaScript.length
};
});
console.log('Source code analysis:', hasEfficientImplementation);
// We expect the fix to be present in the bundled JavaScript
// Note: These might not be detected if the JS is minified/bundled differently
console.log('Memory leak fix has been implemented in maps_controller.js');
console.log('Key improvements:');
console.log('- Incremental marker addition instead of layer recreation');
console.log('- Bounded data structures (1000 point limit)');
console.log('- Efficient last marker tracking');
console.log('- Incremental polyline updates');
// Test passes regardless as we've verified the fix is in the source code
expect(true).toBe(true);
});
});

24
e2e/setup/auth.setup.js Normal file
View file

@ -0,0 +1,24 @@
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/temp/.auth/user.json';
setup('authenticate', async ({ page }) => {
// Navigate to login page with more lenient waiting
await page.goto('/users/sign_in', {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Fill in credentials
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
// Click login button
await page.click('input[type="submit"][value="Log in"]');
// Wait for successful navigation
await page.waitForURL('/map', { timeout: 10000 });
// Save authentication state
await page.context().storageState({ path: authFile });
});

View file

@ -23,27 +23,42 @@ export default defineConfig({
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE_URL || 'http://localhost:3000',
/* Use European locale and timezone */
locale: 'en-GB',
timezoneId: 'Europe/Berlin',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Take screenshot on failure */
screenshot: 'only-on-failure',
/* Record video on failure */
video: 'retain-on-failure',
},
/* Configure projects for major browsers */
projects: [
// Setup project - runs authentication before all tests
{
name: 'setup',
testMatch: /.*\/setup\/auth\.setup\.js/
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
use: {
...devices['Desktop Chrome'],
// Use saved authentication state
storageState: 'e2e/temp/.auth/user.json'
},
dependencies: ['setup'],
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: 'RAILS_ENV=test rails server -p 3000',
command: 'OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES RAILS_ENV=test rails server -p 3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,

View file

@ -1,36 +0,0 @@
listen_addresses = '*'
max_connections = 50
shared_buffers = 512MB
work_mem = 128MB
maintenance_work_mem = 128MB
dynamic_shared_memory_type = posix
checkpoint_timeout = 10min # range 30s-1d
max_wal_size = 2GB
min_wal_size = 80MB
max_parallel_workers_per_gather = 4
log_min_duration_statement = 500 # -1 is disabled, 0 logs all statements
# -1 disables, 0 logs all temp files
log_timezone = 'UTC'
autovacuum_vacuum_scale_factor = 0.05 # fraction of table size before vacuum
autovacuum_analyze_scale_factor = 0.05 # fraction of table size before analyze
datestyle = 'iso, dmy'
timezone = 'UTC'
lc_messages = 'en_US.utf8' # locale for system error message
# strings
lc_monetary = 'en_US.utf8' # locale for monetary formatting
lc_numeric = 'en_US.utf8' # locale for number formatting
lc_time = 'en_US.utf8' # locale for time formatting
default_text_search_config = 'pg_catalog.english'

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Extended Data Example</name>
<Placemark>
<name>Location with Speed</name>
<description>A location with extended data including speed</description>
<TimeStamp>
<when>2024-01-19T11:30:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0841,37.4220,10</coordinates>
</Point>
<ExtendedData>
<Data name="speed">
<value>5.5</value>
</Data>
<Data name="accuracy">
<value>10</value>
</Data>
<Data name="battery">
<value>85</value>
</Data>
</ExtendedData>
</Placemark>
</Document>
</kml>

19
spec/fixtures/files/kml/gx_track.kml vendored Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
<Document>
<name>Google Earth Track</name>
<Placemark>
<name>GPS Track</name>
<gx:Track>
<when>2024-01-20T08:00:00Z</when>
<when>2024-01-20T08:01:00Z</when>
<when>2024-01-20T08:02:00Z</when>
<when>2024-01-20T08:03:00Z</when>
<gx:coord>-122.0841 37.4220 10</gx:coord>
<gx:coord>-122.0851 37.4230 12</gx:coord>
<gx:coord>-122.0861 37.4240 14</gx:coord>
<gx:coord>-122.0871 37.4250 16</gx:coord>
</gx:Track>
</Placemark>
</Document>
</kml>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Invalid Coordinates</name>
<Placemark>
<name>No Coordinates</name>
<TimeStamp>
<when>2024-01-23T10:00:00Z</when>
</TimeStamp>
<Point>
<coordinates></coordinates>
</Point>
</Placemark>
<Placemark>
<name>Only Longitude</name>
<TimeStamp>
<when>2024-01-23T11:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0841</coordinates>
</Point>
</Placemark>
</Document>
</kml>

46
spec/fixtures/files/kml/large_track.kml vendored Normal file
View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Large Track for Batch Testing</name>
<Placemark>
<name>Long Track</name>
<TimeStamp>
<when>2024-01-25T00:00:00Z</when>
</TimeStamp>
<LineString>
<coordinates>
-122.0841,37.4220,10
-122.0842,37.4221,10
-122.0843,37.4222,10
-122.0844,37.4223,10
-122.0845,37.4224,10
-122.0846,37.4225,10
-122.0847,37.4226,10
-122.0848,37.4227,10
-122.0849,37.4228,10
-122.0850,37.4229,10
</coordinates>
</LineString>
</Placemark>
<Placemark>
<name>Another Long Track</name>
<TimeStamp>
<when>2024-01-25T12:00:00Z</when>
</TimeStamp>
<LineString>
<coordinates>
-122.0851,37.4230,12
-122.0852,37.4231,12
-122.0853,37.4232,12
-122.0854,37.4233,12
-122.0855,37.4234,12
-122.0856,37.4235,12
-122.0857,37.4236,12
-122.0858,37.4237,12
-122.0859,37.4238,12
-122.0860,37.4239,12
</coordinates>
</LineString>
</Placemark>
</Document>
</kml>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>LineString Track</name>
<Placemark>
<name>My Track</name>
<TimeStamp>
<when>2024-01-16T10:00:00Z</when>
</TimeStamp>
<LineString>
<coordinates>
-122.0841,37.4220,10
-122.0851,37.4230,12
-122.0861,37.4240,14
-122.0871,37.4250,16
-122.0881,37.4260,18
</coordinates>
</LineString>
</Placemark>
</Document>
</kml>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>MultiGeometry Example</name>
<Placemark>
<name>Multiple Geometries</name>
<TimeStamp>
<when>2024-01-18T15:00:00Z</when>
</TimeStamp>
<MultiGeometry>
<Point>
<coordinates>-122.0841,37.4220,10</coordinates>
</Point>
<Point>
<coordinates>-122.0851,37.4230,12</coordinates>
</Point>
<LineString>
<coordinates>
-122.0861,37.4240,14
-122.0871,37.4250,16
-122.0881,37.4260,18
-122.0891,37.4270,20
</coordinates>
</LineString>
</MultiGeometry>
</Placemark>
</Document>
</kml>

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Nested Folders</name>
<Folder>
<name>Trip 1</name>
<Placemark>
<name>Start Point</name>
<TimeStamp>
<when>2024-01-21T08:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0841,37.4220,10</coordinates>
</Point>
</Placemark>
<Folder>
<name>Day 1</name>
<Placemark>
<name>Checkpoint 1</name>
<TimeStamp>
<when>2024-01-21T12:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0851,37.4230,12</coordinates>
</Point>
</Placemark>
</Folder>
</Folder>
<Folder>
<name>Trip 2</name>
<Placemark>
<name>Location A</name>
<TimeStamp>
<when>2024-01-22T10:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0861,37.4240,14</coordinates>
</Point>
</Placemark>
<Placemark>
<name>Location B</name>
<TimeStamp>
<when>2024-01-22T14:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0871,37.4250,16</coordinates>
</Point>
</Placemark>
</Folder>
</Document>
</kml>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Points with Timestamps</name>
<Placemark>
<name>Location 1</name>
<TimeStamp>
<when>2024-01-15T12:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0841,37.4220,10</coordinates>
</Point>
</Placemark>
<Placemark>
<name>Location 2</name>
<TimeStamp>
<when>2024-01-15T13:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0851,37.4230,15</coordinates>
</Point>
</Placemark>
<Placemark>
<name>Location 3</name>
<TimeStamp>
<when>2024-01-15T14:00:00Z</when>
</TimeStamp>
<Point>
<coordinates>-122.0861,37.4240,20</coordinates>
</Point>
</Placemark>
</Document>
</kml>

16
spec/fixtures/files/kml/timespan.kml vendored Normal file
View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>TimeSpan Example</name>
<Placemark>
<name>Visit Duration</name>
<TimeSpan>
<begin>2024-01-10T09:00:00Z</begin>
<end>2024-01-10T17:00:00Z</end>
</TimeSpan>
<Point>
<coordinates>-122.0841,37.4220,10</coordinates>
</Point>
</Placemark>
</Document>
</kml>

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Family::Invitations::SendingJob, type: :job do
let(:user) { create(:user) }
let(:family) { create(:family, creator: user) }
let(:invitation) { create(:family_invitation, family: family, invited_by: user, status: :pending) }
describe '#perform' do
context 'when invitation exists and is pending' do
it 'sends the invitation email' do
mailer_double = double('mailer')
expect(FamilyMailer).to receive(:invitation).with(invitation).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_now)
described_class.perform_now(invitation.id)
end
end
context 'when invitation does not exist' do
it 'does not raise an error' do
expect do
described_class.perform_now(999_999)
end.not_to raise_error
end
it 'does not send any email' do
expect(FamilyMailer).not_to receive(:invitation)
described_class.perform_now(999_999)
end
end
context 'when invitation is not pending' do
let(:accepted_invitation) do
create(:family_invitation, family: family, invited_by: user, status: :accepted)
end
it 'does not send the invitation email' do
expect(FamilyMailer).not_to receive(:invitation)
described_class.perform_now(accepted_invitation.id)
end
end
context 'when invitation is cancelled' do
let(:cancelled_invitation) do
create(:family_invitation, family: family, invited_by: user, status: :cancelled)
end
it 'does not send the invitation email' do
expect(FamilyMailer).not_to receive(:invitation)
described_class.perform_now(cancelled_invitation.id)
end
end
context 'integration test' do
before do
ActionMailer::Base.deliveries.clear
# Set a from address for the mailer to avoid SMTP errors
allow(ActionMailer::Base).to receive(:default).and_return(from: 'noreply@dawarich.app')
end
it 'actually calls the mailer' do
mailer = instance_double(ActionMailer::MessageDelivery)
allow(FamilyMailer).to receive(:invitation).and_return(mailer)
allow(mailer).to receive(:deliver_now)
described_class.perform_now(invitation.id)
expect(FamilyMailer).to have_received(:invitation).with(invitation)
expect(mailer).to have_received(:deliver_now)
end
end
end
end

View file

@ -26,31 +26,100 @@ RSpec.describe Family, type: :model do
describe '#can_add_members?' do
let(:family) { create(:family, creator: user) }
context 'when family has fewer than max members' do
context 'when not in self-hosted mode' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
end
it 'returns true' do
expect(family.can_add_members?).to be true
context 'when family has fewer than max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
end
it 'returns true' do
expect(family.can_add_members?).to be true
end
end
context 'when family has max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
end
it 'returns false' do
expect(family.can_add_members?).to be false
end
end
context 'when family has pending invitations that would reach max' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
create(:family_invitation, family: family, invited_by: user, status: :pending)
end
it 'returns false' do
expect(family.can_add_members?).to be false
end
end
context 'when family has no members' do
it 'returns true' do
expect(family.can_add_members?).to be true
end
end
end
context 'when family has max members' do
context 'when in self-hosted mode' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
it 'returns false' do
expect(family.can_add_members?).to be false
end
end
context 'when family has fewer than max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
end
context 'when family has no members' do
it 'returns true' do
expect(family.can_add_members?).to be true
it 'returns true' do
expect(family.can_add_members?).to be true
end
end
context 'when family has max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
end
it 'returns true (no limit in self-hosted mode)' do
expect(family.can_add_members?).to be true
end
end
context 'when family has more than max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 10, family: family, role: :member)
end
it 'returns true (no limit in self-hosted mode)' do
expect(family.can_add_members?).to be true
end
end
context 'when family has pending invitations that would exceed max' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
create_list(:family_invitation, 5, family: family, invited_by: user, status: :pending)
end
it 'returns true (no limit in self-hosted mode)' do
expect(family.can_add_members?).to be true
end
end
end
end
@ -122,4 +191,99 @@ RSpec.describe Family, type: :model do
expect(Family::Membership.find_by(id: membership.id)).to be_nil
end
end
describe '#full?' do
let(:family) { create(:family, creator: user) }
context 'when not in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
end
context 'when family has fewer than max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
end
it 'returns false' do
expect(family.full?).to be false
end
end
context 'when family has exactly max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
end
it 'returns true' do
expect(family.full?).to be true
end
end
context 'when family has pending invitations that would reach max' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
create(:family_invitation, family: family, invited_by: user, status: :pending)
end
it 'returns true' do
expect(family.full?).to be true
end
end
end
context 'when in self-hosted mode' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
context 'when family has fewer than max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 3, family: family, role: :member)
end
it 'returns false' do
expect(family.full?).to be false
end
end
context 'when family has exactly max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
end
it 'returns false (no limit in self-hosted mode)' do
expect(family.full?).to be false
end
end
context 'when family has more than max members' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 10, family: family, role: :member)
end
it 'returns false (no limit in self-hosted mode)' do
expect(family.full?).to be false
end
end
context 'when family has pending invitations that would exceed max' do
before do
create(:family_membership, family: family, user: user, role: :owner)
create_list(:family_membership, 4, family: family, role: :member)
create_list(:family_invitation, 5, family: family, invited_by: user, status: :pending)
end
it 'returns false (no limit in self-hosted mode)' do
expect(family.full?).to be false
end
end
end
end
end

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