Merge branch 'dev', remote-tracking branch 'origin' into feature/tracks-on-ruby

This commit is contained in:
Eugene Burmakin
2025-08-29 10:57:08 +02:00
131 changed files with 4384 additions and 928 deletions

View File

@@ -1 +1 @@
0.30.7
0.30.12

View File

@@ -7,6 +7,8 @@ assignees: ''
---
**BEFORE OPENING AN ISSUE, MAKE SURE YOU READ THIS: https://github.com/Freika/dawarich/issues/1382**
**OS & Hardware**
Provide your software and hardware specs

View File

@@ -4,6 +4,68 @@ 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]
## Fixed
- Default value for `points_count` attribute is now set to 0 in the User model.
# [0.30.12] - 2025-08-26
## Fixed
- Number of user points is not being cached resulting in performance boost on certain pages and operations.
- Logout bug
- Api key is now shown even in trial period
# [0.30.11] - 2025-08-23
## Changed
- If user already have import with the same name, it will be appended with timestamp during the import process.
## Fixed
- Some types of imports were not being detected correctly and were failing to import. #1678
# [0.30.10] - 2025-08-22
## Added
- `POST /api/v1/visits` endpoint.
- User now can create visits manually on the map.
- User can now delete a visit by clicking on the delete button in the visit popup.
- Import failure now throws an internal server error.
## Changed
- Source of imports is now being detected automatically.
# [0.30.9] - 2025-08-19
## Changed
- Countries, visited during a trip, are now being calculated from points to improve performance.
## Added
- QR code for API key is implemented but hidden under feature flag until the iOS app supports it.
- X-Dawarich-Response and X-Dawarich-Version headers are now returned for all API responses.
- Trial version for cloud users is now available.
# [0.30.8] - 2025-08-01
## Fixed
- Fog of war is now working correctly on zoom and map movement. #1603
- Possibly fixed a bug where visits were no suggested correctly. #984
- Scratch map is now working correctly.
# [0.30.7] - 2025-08-01
## Fixed
@@ -52,7 +114,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Prometheus metrics are now available at `/metrics`. Configure `METRICS_USERNAME` and `METRICS_PASSWORD` environment variables for basic authentication, default values are `prometheus` for both. All other prometheus-related environment variables are also necessary.
## Fixed
- The Warden error in jobs is now fixed. #1556

View File

@@ -5,6 +5,7 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby File.read('.ruby-version').strip
gem 'activerecord-postgis-adapter'
# https://meta.discourse.org/t/cant-rebuild-due-to-aws-sdk-gem-bump-and-new-aws-data-integrity-protections/354217/40
gem 'aws-sdk-s3', '~> 1.177.0', require: false
gem 'aws-sdk-core', '~> 3.215.1', require: false
@@ -24,7 +25,7 @@ gem 'oj'
gem 'parallel'
gem 'pg'
gem 'prometheus_exporter'
gem 'activerecord-postgis-adapter'
gem 'rqrcode', '~> 3.0'
gem 'puma'
gem 'pundit'
gem 'rails', '~> 8.0'

View File

@@ -10,29 +10,29 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
actioncable (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actionmailbox (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
actionmailer (8.0.2)
actionpack (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activesupport (= 8.0.2)
actionmailer (8.0.2.1)
actionpack (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activesupport (= 8.0.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.0.2)
actionview (= 8.0.2)
activesupport (= 8.0.2)
actionpack (8.0.2.1)
actionview (= 8.0.2.1)
activesupport (= 8.0.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -40,38 +40,38 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.0.2)
actionpack (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
actiontext (8.0.2.1)
actionpack (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.0.2)
activesupport (= 8.0.2)
actionview (8.0.2.1)
activesupport (= 8.0.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.0.2)
activesupport (= 8.0.2)
activejob (8.0.2.1)
activesupport (= 8.0.2.1)
globalid (>= 0.3.6)
activemodel (8.0.2)
activesupport (= 8.0.2)
activerecord (8.0.2)
activemodel (= 8.0.2)
activesupport (= 8.0.2)
activemodel (8.0.2.1)
activesupport (= 8.0.2.1)
activerecord (8.0.2.1)
activemodel (= 8.0.2.1)
activesupport (= 8.0.2.1)
timeout (>= 0.4.0)
activerecord-postgis-adapter (11.0.0)
activerecord (~> 8.0.0)
rgeo-activerecord (~> 8.0.0)
activestorage (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activesupport (= 8.0.2)
activestorage (8.0.2.1)
actionpack (= 8.0.2.1)
activejob (= 8.0.2.1)
activerecord (= 8.0.2.1)
activesupport (= 8.0.2.1)
marcel (~> 1.0)
activesupport (8.0.2)
activesupport (8.0.2.1)
base64
benchmark (>= 0.3)
bigdecimal
@@ -127,6 +127,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.2.0)
chunky_png (1.4.0)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
@@ -297,7 +298,7 @@ GEM
date
stringio
public_suffix (6.0.1)
puma (6.6.0)
puma (6.6.1)
nio4r (~> 2.0)
pundit (2.5.0)
activesupport (>= 3.0.0)
@@ -311,20 +312,20 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails (8.0.2)
actioncable (= 8.0.2)
actionmailbox (= 8.0.2)
actionmailer (= 8.0.2)
actionpack (= 8.0.2)
actiontext (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activemodel (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
rails (8.0.2.1)
actioncable (= 8.0.2.1)
actionmailbox (= 8.0.2.1)
actionmailer (= 8.0.2.1)
actionpack (= 8.0.2.1)
actiontext (= 8.0.2.1)
actionview (= 8.0.2.1)
activejob (= 8.0.2.1)
activemodel (= 8.0.2.1)
activerecord (= 8.0.2.1)
activestorage (= 8.0.2.1)
activesupport (= 8.0.2.1)
bundler (>= 1.15.0)
railties (= 8.0.2)
railties (= 8.0.2.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -332,9 +333,9 @@ GEM
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
railties (8.0.2.1)
actionpack (= 8.0.2.1)
activesupport (= 8.0.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -365,6 +366,10 @@ GEM
rgeo-geojson (2.2.0)
multi_json (~> 1.15)
rgeo (>= 1.0.0)
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.4)
@@ -553,6 +558,7 @@ DEPENDENCIES
rgeo
rgeo-activerecord
rgeo-geojson
rqrcode (~> 3.0)
rspec-rails
rswag-api
rswag-specs

View File

@@ -1,4 +1,4 @@
# 🌍 Dawarich: Your Self-Hosted Location History Tracker
# 🌍 Dawarich: Your Self-Hostable Location History Tracker
[![Discord](https://dcbadge.limes.pink/api/server/pHsBjpt5J8)](https://discord.gg/pHsBjpt5J8) | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/H2H3IDYDD) | [![Patreon](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Fshieldsio-patreon.vercel.app%2Fapi%3Fusername%3Dfreika%26type%3Dpatrons&style=for-the-badge)](https://www.patreon.com/freika)
@@ -21,9 +21,14 @@
## 🗺️ About Dawarich
**Dawarich** is a self-hosted web app designed to replace Google Timeline (aka Google Location History). It enables you to:
If you're looking for Dawarich Cloud, where everything is managed for you, check out [Dawarich Cloud](https://dawarich.app).
**Dawarich** is a self-hostable web app designed to replace Google Timeline (aka Google Location History).
It enables you to:
- Track your location history.
- Visualize your data on an interactive map.
- Import your location history from Google Maps Timeline and Owntracks.
- Import your location history from Google Maps Timeline, OwnTracks, GPX, GeoJSON and some other sources
- Explore statistics like the number of countries and cities visited, total distance traveled, and more!
📄 **Changelog**: Find the latest updates [here](CHANGELOG.md).
@@ -62,7 +67,7 @@ Simply install one of the supported apps on your device and configure it to send
1. Clone the repository.
2. Run the following command to start the app:
```bash
docker-compose up
docker-compose -f docker/docker-compose.yml up
```
3. Access the app at `http://localhost:3000`.

File diff suppressed because one or more lines are too long

View File

@@ -33,6 +33,40 @@
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
/* Add Visit Marker Styles */
.add-visit-marker {
display: flex !important;
align-items: center;
justify-content: center;
font-size: 20px;
background: white;
border: 2px solid #007bff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
animation: pulse-visit 2s infinite;
}
@keyframes pulse-visit {
0% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
50% {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.5);
}
100% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3);
}
}
/* Visit Form Popup Styles */
.visit-form-popup .leaflet-popup-content-wrapper {
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.leaflet-right-panel.controls-shifted {
right: 310px;
}

View File

@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Api::V1::Countries::BordersController < ApplicationController
class Api::V1::Countries::BordersController < ApiController
def index
countries = Rails.cache.fetch('dawarich/countries_codes', expires_in: 1.day) do
Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson')))

View File

@@ -8,7 +8,7 @@ class Api::V1::Countries::VisitedCitiesController < ApiController
end_at = DateTime.parse(params[:end_at]).to_i
points = current_api_user
.tracked_points
.points
.where(timestamp: start_at..end_at)
render json: { data: CountriesAndCities.new(points).call }

View File

@@ -4,14 +4,6 @@ class Api::V1::HealthController < ApiController
skip_before_action :authenticate_api_key
def index
if current_api_user
response.set_header('X-Dawarich-Response', 'Hey, I\'m alive and authenticated!')
else
response.set_header('X-Dawarich-Response', 'Hey, I\'m alive!')
end
response.set_header('X-Dawarich-Version', APP_VERSION)
render json: { status: 'ok' }
end
end

View File

@@ -10,7 +10,7 @@ class Api::V1::PointsController < ApiController
order = params[:order] || 'desc'
points = current_api_user
.tracked_points
.points
.where(timestamp: start_at..end_at)
.order(timestamp: order)
.page(params[:page])
@@ -31,7 +31,7 @@ class Api::V1::PointsController < ApiController
end
def update
point = current_api_user.tracked_points.find(params[:id])
point = current_api_user.points.find(params[:id])
point.update(lonlat: "POINT(#{point_params[:longitude]} #{point_params[:latitude]})")
@@ -39,7 +39,7 @@ class Api::V1::PointsController < ApiController
end
def destroy
point = current_api_user.tracked_points.find(params[:id])
point = current_api_user.points.find(params[:id])
point.destroy
render json: { message: 'Point deleted successfully' }

View File

@@ -10,6 +10,19 @@ class Api::V1::VisitsController < ApiController
render json: serialized_visits
end
def create
service = Visits::Create.new(current_api_user, visit_params)
result = service.call
if result
render json: Api::VisitSerializer.new(service.visit).call
else
error_message = service.errors || 'Failed to create visit'
render json: { error: error_message }, status: :unprocessable_entity
end
end
def update
visit = current_api_user.visits.find(params[:id])
visit = update_visit(visit)
@@ -62,10 +75,25 @@ class Api::V1::VisitsController < ApiController
end
end
def destroy
visit = current_api_user.visits.find(params[:id])
if visit.destroy
head :no_content
else
render json: {
error: 'Failed to delete visit',
errors: visit.errors.full_messages
}, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotFound
render json: { error: 'Visit not found' }, status: :not_found
end
private
def visit_params
params.require(:visit).permit(:name, :place_id, :status)
params.require(:visit).permit(:name, :place_id, :status, :latitude, :longitude, :started_at, :ended_at)
end
def merge_params
@@ -78,6 +106,8 @@ class Api::V1::VisitsController < ApiController
def update_visit(visit)
visit_params.each do |key, value|
next if %w[latitude longitude].include?(key.to_s)
visit[key] = value
visit.name = visit.place.name if visit_params[:place_id].present?
end

View File

@@ -2,10 +2,18 @@
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :set_version_header
before_action :authenticate_api_key
private
def set_version_header
message = "Hey, I\'m alive#{current_api_user ? ' and authenticated' : ''}!"
response.set_header('X-Dawarich-Response', message)
response.set_header('X-Dawarich-Version', APP_VERSION)
end
def authenticate_api_key
return head :unauthorized unless current_api_user

View File

@@ -6,6 +6,6 @@ class HomeController < ApplicationController
redirect_to map_url if current_user
@points = current_user.tracked_points.without_raw_data if current_user
@points = current_user.points.without_raw_data if current_user
end
end

View File

@@ -43,8 +43,7 @@ class ImportsController < ApplicationController
raw_files = Array(files_params).reject(&:blank?)
if raw_files.empty?
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity
return
redirect_to new_import_path, alert: 'No files were selected for upload', status: :unprocessable_entity and return
end
created_imports = []
@@ -59,11 +58,11 @@ class ImportsController < ApplicationController
if created_imports.any?
redirect_to imports_url,
notice: "#{created_imports.size} files are queued to be imported in background",
status: :see_other
status: :see_other and return
else
redirect_to new_import_path,
alert: 'No valid file references were found. Please upload files using the file selector.',
status: :unprocessable_entity
status: :unprocessable_entity and return
end
rescue StandardError => e
if created_imports.present?
@@ -95,7 +94,7 @@ class ImportsController < ApplicationController
end
def import_params
params.require(:import).permit(:name, :source, files: [])
params.require(:import).permit(:name, files: [])
end
def create_import_from_signed_id(signed_id)
@@ -103,11 +102,8 @@ class ImportsController < ApplicationController
blob = ActiveStorage::Blob.find_signed(signed_id)
import = current_user.imports.build(
name: blob.filename.to_s,
source: params[:import][:source]
)
import_name = generate_unique_import_name(blob.filename.to_s)
import = current_user.imports.build(name: import_name)
import.file.attach(blob)
import.save!
@@ -115,6 +111,18 @@ class ImportsController < ApplicationController
import
end
def generate_unique_import_name(original_name)
return original_name unless current_user.imports.exists?(name: original_name)
# Extract filename and extension
basename = File.basename(original_name, File.extname(original_name))
extension = File.extname(original_name)
# Add current datetime
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
"#{basename}_#{timestamp}#{extension}"
end
def validate_points_limit
limit_exceeded = PointsLimitExceeded.new(current_user).call

View File

@@ -88,6 +88,6 @@ class MapController < ApplicationController
end
def points_from_user
current_user.tracked_points.without_raw_data.order(timestamp: :asc)
current_user.points.without_raw_data.order(timestamp: :asc)
end
end

View File

@@ -24,7 +24,7 @@ class PointsController < ApplicationController
alert: 'No points selected.',
status: :see_other and return if point_ids.blank?
current_user.tracked_points.where(id: point_ids).destroy_all
current_user.points.where(id: point_ids).destroy_all
redirect_to points_url(preserved_params),
notice: 'Points were successfully destroyed.',
@@ -58,7 +58,7 @@ class PointsController < ApplicationController
end
def user_points
current_user.tracked_points
current_user.points
end
def order_by

View File

@@ -6,7 +6,7 @@ class Settings::BackgroundJobsController < ApplicationController
%w[start_immich_import start_photoprism_import].include?(params[:job_name])
}
def index;end
def index; end
def create
EnqueueBackgroundJob.perform_later(params[:job_name], current_user.id)

View File

@@ -59,11 +59,11 @@ class StatsController < ApplicationController
@stats.each do |year, stats|
stats_by_month = stats.index_by(&:month)
year_distances[year] = (1..12).map do |month|
month_name = Date::MONTHNAMES[month]
distance = stats_by_month[month]&.distance || 0
[month_name, distance]
end
end

View File

@@ -48,11 +48,11 @@ module ApplicationHelper
grouped_by_country[country] ||= []
if toponym['cities'].present?
toponym['cities'].each do |city_data|
city = city_data['city']
grouped_by_country[country] << city if city.present?
end
next unless toponym['cities'].present?
toponym['cities'].each do |city_data|
city = city_data['city']
grouped_by_country[country] << city if city.present?
end
end
end
@@ -86,7 +86,7 @@ module ApplicationHelper
end
def points_exist?(year, month, user)
user.tracked_points.where(
user.points.where(
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
).exists?
end
@@ -172,4 +172,21 @@ module ApplicationHelper
data: { tip: "Expires on #{active_until.iso8601}" }
)
end
def onboarding_modal_showable?(user)
user.trial_state?
end
def trial_button_class(user)
case (user.active_until.to_date - Time.current.to_date).to_i
when 5..8
'btn-info'
when 2...5
'btn-warning'
when 0...2
'btn-error'
else
'btn-success'
end
end
end

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
module UserHelper
def api_key_qr_code(user)
qrcode = RQRCode::QRCode.new(user.api_key)
svg = qrcode.as_svg(
color: "000",
fill: "fff",
shape_rendering: "crispEdges",
module_size: 11,
standalone: true,
use_path: true,
offset: 5
)
svg.html_safe
end
end

View File

@@ -0,0 +1,462 @@
import { Controller } from "@hotwired/stimulus";
import L from "leaflet";
import { showFlashMessage } from "../maps/helpers";
export default class extends Controller {
static targets = [""];
static values = {
apiKey: String
}
connect() {
console.log("Add visit controller connected");
this.map = null;
this.isAddingVisit = false;
this.addVisitMarker = null;
this.addVisitButton = null;
this.currentPopup = null;
this.mapsController = null;
// Wait for the map to be initialized
this.waitForMap();
}
disconnect() {
this.cleanup();
console.log("Add visit controller disconnected");
}
waitForMap() {
// Get the map from the maps controller instance
const mapElement = document.querySelector('[data-controller*="maps"]');
if (mapElement) {
// Try to get Stimulus controller instance
const stimulusController = this.application.getControllerForElementAndIdentifier(mapElement, 'maps');
if (stimulusController && stimulusController.map) {
this.map = stimulusController.map;
this.mapsController = stimulusController;
this.apiKeyValue = stimulusController.apiKey;
this.setupAddVisitButton();
return;
}
}
// Fallback: check for map container and try to find map instance
const mapContainer = document.getElementById('map');
if (mapContainer && mapContainer._leaflet_id) {
// Get map instance from Leaflet registry
this.map = window.L._getMap ? window.L._getMap(mapContainer._leaflet_id) : null;
if (!this.map) {
// Try through Leaflet internal registry
const maps = window.L.Map._instances || {};
this.map = maps[mapContainer._leaflet_id];
}
if (this.map) {
// Get API key from map element data
this.apiKeyValue = mapContainer.dataset.api_key || this.element.dataset.apiKey;
this.setupAddVisitButton();
return;
}
}
// Wait a bit more for the map to initialize
setTimeout(() => this.waitForMap(), 200);
}
setupAddVisitButton() {
if (!this.map || this.addVisitButton) return;
// Create the Add Visit control
const AddVisitControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-control-button add-visit-button');
button.innerHTML = '';
button.title = 'Add a visit';
// Style the button to match other map controls
button.style.width = '48px';
button.style.height = '48px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
button.style.backgroundColor = 'white';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
button.style.transition = 'all 0.2s ease';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Add hover effects
button.addEventListener('mouseenter', () => {
if (!this.isAddingVisit) {
button.style.backgroundColor = '#f0f0f0';
}
});
button.addEventListener('mouseleave', () => {
if (!this.isAddingVisit) {
button.style.backgroundColor = 'white';
}
});
// Toggle add visit mode on button click
L.DomEvent.on(button, 'click', () => {
this.toggleAddVisitMode(button);
});
this.addVisitButton = button;
return button;
}
});
// Add the control to the map (top right, below existing buttons)
this.map.addControl(new AddVisitControl({ position: 'topright' }));
}
toggleAddVisitMode(button) {
if (this.isAddingVisit) {
// Exit add visit mode
this.exitAddVisitMode(button);
} else {
// Enter add visit mode
this.enterAddVisitMode(button);
}
}
enterAddVisitMode(button) {
this.isAddingVisit = true;
// Update button style to show active state
button.style.backgroundColor = '#dc3545';
button.style.color = 'white';
button.innerHTML = '✕';
// Change cursor to crosshair
this.map.getContainer().style.cursor = 'crosshair';
// Add map click listener
this.map.on('click', this.onMapClick, this);
showFlashMessage('notice', 'Click on the map to place a visit');
}
exitAddVisitMode(button) {
this.isAddingVisit = false;
// Reset button style
button.style.backgroundColor = 'white';
button.style.color = 'black';
button.innerHTML = '';
// Reset cursor
this.map.getContainer().style.cursor = '';
// Remove map click listener
this.map.off('click', this.onMapClick, this);
// Remove any existing marker
if (this.addVisitMarker) {
this.map.removeLayer(this.addVisitMarker);
this.addVisitMarker = null;
}
// Close any open popup
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
}
}
onMapClick(e) {
if (!this.isAddingVisit) return;
const { lat, lng } = e.latlng;
// Remove existing marker if any
if (this.addVisitMarker) {
this.map.removeLayer(this.addVisitMarker);
}
// Create a new marker at the clicked location
this.addVisitMarker = L.marker([lat, lng], {
draggable: true,
icon: L.divIcon({
className: 'add-visit-marker',
html: '📍',
iconSize: [30, 30],
iconAnchor: [15, 15]
})
}).addTo(this.map);
// Show the visit form popup
this.showVisitForm(lat, lng);
}
showVisitForm(lat, lng) {
// Get current date/time for default values
const now = new Date();
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000));
// Format dates for datetime-local input
const formatDateTime = (date) => {
return date.toISOString().slice(0, 16);
};
const startTime = formatDateTime(now);
const endTime = formatDateTime(oneHourLater);
// Create form HTML
const formHTML = `
<div class="visit-form" style="min-width: 280px;">
<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; color: #333;">Add New Visit</h3>
<form id="add-visit-form" style="display: flex; flex-direction: column; gap: 10px;">
<div>
<label for="visit-name" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Name:</label>
<input type="text" id="visit-name" name="name" required
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;"
placeholder="Enter visit name">
</div>
<div>
<label for="visit-start" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Start Time:</label>
<input type="datetime-local" id="visit-start" name="started_at" required value="${startTime}"
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
</div>
<div>
<label for="visit-end" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">End Time:</label>
<input type="datetime-local" id="visit-end" name="ended_at" required value="${endTime}"
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
</div>
<input type="hidden" name="latitude" value="${lat}">
<input type="hidden" name="longitude" value="${lng}">
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button type="submit" style="flex: 1; background: #28a745; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
Create Visit
</button>
<button type="button" id="cancel-visit" style="flex: 1; background: #dc3545; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
Cancel
</button>
</div>
</form>
</div>
`;
// Create popup at the marker location
this.currentPopup = L.popup({
closeOnClick: false,
autoClose: false,
maxWidth: 300,
className: 'visit-form-popup'
})
.setLatLng([lat, lng])
.setContent(formHTML)
.openOn(this.map);
// Add event listeners after the popup is added to DOM
setTimeout(() => {
const form = document.getElementById('add-visit-form');
const cancelButton = document.getElementById('cancel-visit');
const nameInput = document.getElementById('visit-name');
if (form) {
form.addEventListener('submit', (e) => this.handleFormSubmit(e));
}
if (cancelButton) {
cancelButton.addEventListener('click', () => {
this.exitAddVisitMode(this.addVisitButton);
});
}
// Focus the name input
if (nameInput) {
nameInput.focus();
}
}, 100);
}
async handleFormSubmit(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
// Get form values
const visitData = {
visit: {
name: formData.get('name'),
started_at: formData.get('started_at'),
ended_at: formData.get('ended_at'),
latitude: formData.get('latitude'),
longitude: formData.get('longitude')
}
};
// Validate that end time is after start time
const startTime = new Date(visitData.visit.started_at);
const endTime = new Date(visitData.visit.ended_at);
if (endTime <= startTime) {
showFlashMessage('error', 'End time must be after start time');
return;
}
// Disable form while submitting
const submitButton = form.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.disabled = true;
submitButton.textContent = 'Creating...';
try {
const response = await fetch(`/api/v1/visits`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}`
},
body: JSON.stringify(visitData)
});
const data = await response.json();
if (response.ok) {
showFlashMessage('notice', `Visit "${visitData.visit.name}" created successfully!`);
this.exitAddVisitMode(this.addVisitButton);
// Refresh visits layer - this will clear and refetch data
this.refreshVisitsLayer();
// Ensure confirmed visits layer is enabled (with a small delay for the API call to complete)
setTimeout(() => {
this.ensureVisitsLayersEnabled();
}, 300);
} else {
const errorMessage = data.error || data.message || 'Failed to create visit';
showFlashMessage('error', errorMessage);
}
} catch (error) {
console.error('Error creating visit:', error);
showFlashMessage('error', 'Network error: Failed to create visit');
} finally {
// Re-enable form
submitButton.disabled = false;
submitButton.textContent = originalText;
}
}
refreshVisitsLayer() {
console.log('Attempting to refresh visits layer...');
// Try multiple approaches to refresh the visits layer
const mapsController = document.querySelector('[data-controller*="maps"]');
if (mapsController) {
// Try to get the Stimulus controller instance
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
if (stimulusController && stimulusController.visitsManager) {
console.log('Found maps controller with visits manager');
// Clear existing visits and fetch fresh data
if (stimulusController.visitsManager.visitCircles) {
stimulusController.visitsManager.visitCircles.clearLayers();
}
if (stimulusController.visitsManager.confirmedVisitCircles) {
stimulusController.visitsManager.confirmedVisitCircles.clearLayers();
}
// Refresh the visits data
if (typeof stimulusController.visitsManager.fetchAndDisplayVisits === 'function') {
console.log('Refreshing visits data...');
stimulusController.visitsManager.fetchAndDisplayVisits();
}
} else {
console.log('Could not find maps controller or visits manager');
// Fallback: Try to dispatch a custom event
const refreshEvent = new CustomEvent('visits:refresh', { bubbles: true });
mapsController.dispatchEvent(refreshEvent);
}
} else {
console.log('Could not find maps controller element');
}
}
ensureVisitsLayersEnabled() {
console.log('Ensuring visits layers are enabled...');
const mapsController = document.querySelector('[data-controller*="maps"]');
if (mapsController) {
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
if (stimulusController && stimulusController.map && stimulusController.visitsManager) {
const map = stimulusController.map;
const visitsManager = stimulusController.visitsManager;
// Get the confirmed visits layer (newly created visits are always confirmed)
const confirmedVisitsLayer = visitsManager.getConfirmedVisitCirclesLayer();
// Ensure confirmed visits layer is added to map since we create confirmed visits
if (confirmedVisitsLayer && !map.hasLayer(confirmedVisitsLayer)) {
console.log('Adding confirmed visits layer to map');
map.addLayer(confirmedVisitsLayer);
// Update the layer control checkbox to reflect the layer is now active
this.updateLayerControlCheckbox('Confirmed Visits', true);
}
// Refresh visits data to include the new visit
if (typeof visitsManager.fetchAndDisplayVisits === 'function') {
console.log('Final refresh of visits to show new visit...');
visitsManager.fetchAndDisplayVisits();
}
}
}
}
updateLayerControlCheckbox(layerName, isEnabled) {
// Find the layer control input for the specified layer
const layerControlContainer = document.querySelector('.leaflet-control-layers');
if (!layerControlContainer) {
console.log('Layer control container not found');
return;
}
const inputs = layerControlContainer.querySelectorAll('input[type="checkbox"]');
inputs.forEach(input => {
const label = input.nextElementSibling;
if (label && label.textContent.trim() === layerName) {
console.log(`Updating ${layerName} checkbox to ${isEnabled}`);
input.checked = isEnabled;
// Trigger change event to ensure proper state management
input.dispatchEvent(new Event('change', { bubbles: true }));
}
});
}
cleanup() {
if (this.map) {
this.map.off('click', this.onMapClick, this);
if (this.addVisitMarker) {
this.map.removeLayer(this.addVisitMarker);
}
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
}
}
}
}

View File

@@ -5,7 +5,8 @@ import { showFlashMessage } from "../maps/helpers"
export default class extends Controller {
static targets = ["input", "progress", "progressBar", "submit", "form"]
static values = {
url: String
url: String,
userTrial: Boolean
}
connect() {
@@ -50,6 +51,22 @@ export default class extends Controller {
const files = this.inputTarget.files
if (files.length === 0) return
// Check file size limits for trial users
if (this.userTrialValue) {
const MAX_FILE_SIZE = 11 * 1024 * 1024 // 11MB in bytes
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE)
if (oversizedFiles.length > 0) {
const fileNames = oversizedFiles.map(f => f.name).join(', ')
const message = `File size limit exceeded. Trial users can only upload files up to 10MB. Oversized files: ${fileNames}`
showFlashMessage('error', message)
// Clear the file input
this.inputTarget.value = ''
return
}
}
console.log(`Uploading ${files.length} files`)
this.isUploading = true

View File

@@ -35,6 +35,7 @@ import { showFlashMessage } from "../maps/helpers";
import { fetchAndDisplayPhotos } from "../maps/photos";
import { countryCodesMap } from "../maps/country_codes";
import { VisitsManager } from "../maps/visits";
import { ScratchLayer } from "../maps/scratch_layer";
import "leaflet-draw";
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
@@ -49,7 +50,6 @@ export default class extends BaseController {
layerControl = null;
visitedCitiesCache = new Map();
trackedMonthsCache = null;
currentPopup = null;
tracksLayer = null;
tracksVisible = false;
tracksSubscription = null;
@@ -181,7 +181,7 @@ export default class extends BaseController {
this.areasLayer = new L.FeatureGroup();
this.photoMarkers = L.layerGroup();
this.setupScratchLayer(this.countryCodesMap);
this.initializeScratchLayer();
if (!this.settingsButtonAdded) {
this.addSettingsButton();
@@ -197,7 +197,7 @@ export default class extends BaseController {
Tracks: this.tracksLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayer,
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer,
Photos: this.photoMarkers,
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
@@ -348,127 +348,23 @@ export default class extends BaseController {
appendPoint(data) {
if (this.liveMapHandler && this.liveMapEnabled) {
this.liveMapHandler.appendPoint(data);
// Update scratch layer manager with new markers
if (this.scratchLayerManager) {
this.scratchLayerManager.updateMarkers(this.markers);
}
} else {
console.warn('LiveMapHandler not initialized or live mode not enabled');
}
}
async setupScratchLayer(countryCodesMap) {
this.scratchLayer = L.geoJSON(null, {
style: {
fillColor: '#FFD700',
fillOpacity: 0.3,
color: '#FFA500',
weight: 1
}
})
try {
// Up-to-date version can be found on Github:
// https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson
const response = await fetch('/api/v1/countries/borders.json', {
headers: {
'Accept': 'application/geo+json,application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const worldData = await response.json();
// Cache the world borders data for future use
this.worldBordersData = worldData;
const visitedCountries = this.getVisitedCountries(countryCodesMap)
const filteredFeatures = worldData.features.filter(feature =>
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
)
this.scratchLayer.addData({
type: 'FeatureCollection',
features: filteredFeatures
})
} catch (error) {
console.error('Error loading GeoJSON:', error);
}
async initializeScratchLayer() {
this.scratchLayerManager = new ScratchLayer(this.map, this.markers, this.countryCodesMap, this.apiKey);
this.scratchLayer = await this.scratchLayerManager.setup();
}
getVisitedCountries(countryCodesMap) {
if (!this.markers) return [];
return [...new Set(
this.markers
.filter(marker => marker[7]) // Ensure country exists
.map(marker => {
// Convert country name to ISO code, or return the original if not found
return countryCodesMap[marker[7]] || marker[7];
})
)];
}
// Optional: Add methods to handle user interactions
toggleScratchLayer() {
if (this.map.hasLayer(this.scratchLayer)) {
this.map.removeLayer(this.scratchLayer)
} else {
this.scratchLayer.addTo(this.map)
}
}
async refreshScratchLayer() {
console.log('Refreshing scratch layer with current data');
if (!this.scratchLayer) {
console.log('Scratch layer not initialized, setting up');
await this.setupScratchLayer(this.countryCodesMap);
return;
}
try {
// Clear existing data
this.scratchLayer.clearLayers();
// Get current visited countries based on current markers
const visitedCountries = this.getVisitedCountries(this.countryCodesMap);
console.log('Current visited countries:', visitedCountries);
if (visitedCountries.length === 0) {
console.log('No visited countries found');
return;
}
// Fetch country borders data (reuse if already loaded)
if (!this.worldBordersData) {
console.log('Loading world borders data');
const response = await fetch('/api/v1/countries/borders.json', {
headers: {
'Accept': 'application/geo+json,application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
this.worldBordersData = await response.json();
}
// Filter for visited countries
const filteredFeatures = this.worldBordersData.features.filter(feature =>
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
);
console.log('Filtered features for visited countries:', filteredFeatures.length);
// Add the filtered country data to the scratch layer
this.scratchLayer.addData({
type: 'FeatureCollection',
features: filteredFeatures
});
} catch (error) {
console.error('Error refreshing scratch layer:', error);
if (this.scratchLayerManager) {
this.scratchLayerManager.toggle();
}
}
@@ -591,9 +487,11 @@ export default class extends BaseController {
this.visitsManager.fetchAndDisplayVisits();
}
} else if (event.name === 'Scratch map') {
// Refresh scratch map with current visited countries
// Add scratch map layer
console.log('Scratch map layer enabled via layer control');
this.refreshScratchLayer();
if (this.scratchLayerManager) {
this.scratchLayerManager.addToMap();
}
} else if (event.name === 'Fog of War') {
// Enable fog of war when layer is added
this.fogOverlay = event.layer;
@@ -626,6 +524,12 @@ export default class extends BaseController {
// Clear the visit circles when layer is disabled
this.visitsManager.visitCircles.clearLayers();
}
} else if (event.name === 'Scratch map') {
// Handle scratch map layer removal
console.log('Scratch map layer disabled via layer control');
if (this.scratchLayerManager) {
this.scratchLayerManager.remove();
}
} else if (event.name === 'Fog of War') {
// Fog canvas will be automatically removed by the layer's onRemove method
this.fogOverlay = null;
@@ -703,7 +607,7 @@ export default class extends BaseController {
Routes: this.polylinesLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.layerGroup(),
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayer || L.layerGroup(),
"Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup()
};
@@ -741,24 +645,26 @@ export default class extends BaseController {
const markerId = parseInt(marker[6]);
return markerId !== numericId;
});
}
}
addLastMarker(map, markers) {
if (markers.length > 0) {
const lastMarker = markers[markers.length - 1].slice(0, 2);
const marker = L.marker(lastMarker).addTo(map);
return marker; // Return marker reference for tracking
// Update scratch layer manager with updated markers
if (this.scratchLayerManager) {
this.scratchLayerManager.updateMarkers(this.markers);
}
}
return null;
}
updateFog(markers, clearFogRadius, fogLineThreshold) {
const fog = document.getElementById('fog');
if (!fog) {
initializeFogCanvas(this.map);
// Call the fog overlay's updateFog method if it exists
if (this.fogOverlay && typeof this.fogOverlay.updateFog === 'function') {
this.fogOverlay.updateFog(markers, clearFogRadius, fogLineThreshold);
} else {
// Fallback for when fog overlay isn't available
const fog = document.getElementById('fog');
if (!fog) {
initializeFogCanvas(this.map);
}
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold));
}
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold));
}
initializeDrawControl() {
@@ -1098,7 +1004,7 @@ export default class extends BaseController {
Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false,
Heatmap: this.map.hasLayer(this.heatmapLayer),
"Fog of War": this.map.hasLayer(this.fogOverlay),
"Scratch map": this.map.hasLayer(this.scratchLayer),
"Scratch map": this.scratchLayerManager?.isVisible() || false,
Areas: this.map.hasLayer(this.areasLayer),
Photos: this.map.hasLayer(this.photoMarkers)
};
@@ -1640,14 +1546,6 @@ export default class extends BaseController {
}
}
chunk(array, size) {
const chunked = [];
for (let i = 0; i < array.length; i += size) {
chunked.push(array.slice(i, i + size));
}
return chunked;
}
getWholeYearLink() {
// First try to get year from URL parameters
const urlParams = new URLSearchParams(window.location.search);
@@ -1912,30 +1810,6 @@ export default class extends BaseController {
});
}
updateLayerControl() {
if (!this.layerControl) return;
// Remove existing layer control
this.map.removeControl(this.layerControl);
// Create new controls layer object
const controlsLayer = {
Points: this.markersLayer || L.layerGroup(),
Routes: this.polylinesLayer || L.layerGroup(),
Tracks: this.tracksLayer || L.layerGroup(),
Heatmap: this.heatmapLayer || L.heatLayer([]),
"Fog of War": this.fogOverlay,
"Scratch map": this.scratchLayer || L.layerGroup(),
Areas: this.areasLayer || L.layerGroup(),
Photos: this.photoMarkers || L.layerGroup(),
"Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(),
"Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup()
};
// Re-add the layer control
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
}
toggleTracksVisibility(event) {
this.tracksVisible = event.target.checked;
@@ -1943,8 +1817,4 @@ export default class extends BaseController {
toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible);
}
}
}

View File

@@ -0,0 +1,42 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal"]
static values = { showable: Boolean }
connect() {
if (this.showableValue) {
// Listen for Turbo page load events to show modal after navigation completes
document.addEventListener('turbo:load', this.handleTurboLoad.bind(this))
}
}
disconnect() {
// Clean up event listener when controller is removed
document.removeEventListener('turbo:load', this.handleTurboLoad.bind(this))
}
handleTurboLoad() {
if (this.showableValue) {
this.checkAndShowModal()
}
}
checkAndShowModal() {
const MODAL_STORAGE_KEY = 'dawarich_onboarding_shown'
const hasShownModal = localStorage.getItem(MODAL_STORAGE_KEY)
if (!hasShownModal && this.hasModalTarget) {
// Show the modal
this.modalTarget.showModal()
// Mark as shown in local storage
localStorage.setItem(MODAL_STORAGE_KEY, 'true')
// Add event listener to handle when modal is closed
this.modalTarget.addEventListener('close', () => {
// Modal closed - state already saved
})
}
}
}

View File

@@ -33,7 +33,12 @@ export function drawFogCanvas(map, markers, clearFogRadius, fogLineThreshold) {
const size = map.getSize();
// 1) Paint base fog
// Update canvas size if needed
if (fog.width !== size.x || fog.height !== size.y) {
fog.width = size.x;
fog.height = size.y;
}
// 1) Paint base fog
ctx.clearRect(0, 0, size.x, size.y);
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillRect(0, 0, size.x, size.y);
@@ -106,23 +111,17 @@ export function createFogOverlay() {
return L.Layer.extend({
onAdd: function(map) {
this._map = map;
// Initialize storage for fog parameters
this._markers = [];
this._clearFogRadius = 50;
this._fogLineThreshold = 90;
// Initialize the fog canvas
initializeFogCanvas(map);
// Get the map controller to access markers and settings
const mapElement = document.getElementById('map');
if (mapElement && mapElement._stimulus_controllers) {
const controller = mapElement._stimulus_controllers.find(c => c.identifier === 'maps');
if (controller) {
this._controller = controller;
// Draw initial fog if we have markers
if (controller.markers && controller.markers.length > 0) {
drawFogCanvas(map, controller.markers, controller.clearFogRadius, controller.fogLineThreshold);
}
}
}
// Fog overlay will be initialized via updateFog() call from maps controller
// No need to try to access controller data here
// Add resize event handlers to update fog size
this._onResize = () => {
@@ -139,7 +138,31 @@ export function createFogOverlay() {
}
};
// Add event handlers for zoom and pan to update fog position
this._onMoveEnd = () => {
console.log('Fog: moveend event fired');
if (this._markers && this._markers.length > 0) {
console.log('Fog: redrawing after move with stored data');
drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold);
} else {
console.log('Fog: no stored markers available');
}
};
this._onZoomEnd = () => {
console.log('Fog: zoomend event fired');
if (this._markers && this._markers.length > 0) {
console.log('Fog: redrawing after zoom with stored data');
drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold);
} else {
console.log('Fog: no stored markers available');
}
};
// Bind event listeners
map.on('resize', this._onResize);
map.on('moveend', this._onMoveEnd);
map.on('zoomend', this._onZoomEnd);
},
onRemove: function(map) {
@@ -148,16 +171,28 @@ export function createFogOverlay() {
fog.remove();
}
// Clean up event listener
// Clean up event listeners
if (this._onResize) {
map.off('resize', this._onResize);
}
if (this._onMoveEnd) {
map.off('moveend', this._onMoveEnd);
}
if (this._onZoomEnd) {
map.off('zoomend', this._onZoomEnd);
}
},
// Method to update fog when markers change
updateFog: function(markers, clearFogRadius, fogLineThreshold) {
if (this._map) {
drawFogCanvas(this._map, markers, clearFogRadius, fogLineThreshold);
// Store the updated parameters
this._markers = markers || [];
this._clearFogRadius = clearFogRadius || 50;
this._fogLineThreshold = fogLineThreshold || 90;
console.log('Fog: updateFog called with', markers?.length || 0, 'markers');
drawFogCanvas(this._map, this._markers, this._clearFogRadius, this._fogLineThreshold);
}
}
});

View File

@@ -0,0 +1,171 @@
import L from "leaflet";
export class ScratchLayer {
constructor(map, markers, countryCodesMap, apiKey) {
this.map = map;
this.markers = markers;
this.countryCodesMap = countryCodesMap;
this.apiKey = apiKey;
this.scratchLayer = null;
this.worldBordersData = null;
}
async setup() {
this.scratchLayer = L.geoJSON(null, {
style: {
fillColor: '#FFD700',
fillOpacity: 0.3,
color: '#FFA500',
weight: 1
}
});
try {
// Up-to-date version can be found on Github:
// https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson
const worldData = await this._fetchWorldBordersData();
const visitedCountries = this.getVisitedCountries();
console.log('Current visited countries:', visitedCountries);
if (visitedCountries.length === 0) {
console.log('No visited countries found');
return this.scratchLayer;
}
const filteredFeatures = worldData.features.filter(feature =>
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
);
console.log('Filtered features for visited countries:', filteredFeatures.length);
this.scratchLayer.addData({
type: 'FeatureCollection',
features: filteredFeatures
});
} catch (error) {
console.error('Error loading GeoJSON:', error);
}
return this.scratchLayer;
}
async _fetchWorldBordersData() {
if (this.worldBordersData) {
return this.worldBordersData;
}
console.log('Loading world borders data');
const response = await fetch('/api/v1/countries/borders.json', {
headers: {
'Accept': 'application/geo+json,application/json',
'Authorization': `Bearer ${this.apiKey}`
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
this.worldBordersData = await response.json();
return this.worldBordersData;
}
getVisitedCountries() {
if (!this.markers) return [];
return [...new Set(
this.markers
.filter(marker => marker[7]) // Ensure country exists
.map(marker => {
// Convert country name to ISO code, or return the original if not found
return this.countryCodesMap[marker[7]] || marker[7];
})
)];
}
toggle() {
if (!this.scratchLayer) {
console.warn('Scratch layer not initialized');
return;
}
if (this.map.hasLayer(this.scratchLayer)) {
this.map.removeLayer(this.scratchLayer);
} else {
this.scratchLayer.addTo(this.map);
}
}
async refresh() {
console.log('Refreshing scratch layer with current data');
if (!this.scratchLayer) {
console.log('Scratch layer not initialized, setting up');
await this.setup();
return;
}
try {
// Clear existing data
this.scratchLayer.clearLayers();
// Get current visited countries based on current markers
const visitedCountries = this.getVisitedCountries();
console.log('Current visited countries:', visitedCountries);
if (visitedCountries.length === 0) {
console.log('No visited countries found');
return;
}
// Fetch country borders data (reuse if already loaded)
const worldData = await this._fetchWorldBordersData();
// Filter for visited countries
const filteredFeatures = worldData.features.filter(feature =>
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
);
console.log('Filtered features for visited countries:', filteredFeatures.length);
// Add the filtered country data to the scratch layer
this.scratchLayer.addData({
type: 'FeatureCollection',
features: filteredFeatures
});
} catch (error) {
console.error('Error refreshing scratch layer:', error);
}
}
// Update markers reference when they change
updateMarkers(markers) {
this.markers = markers;
}
// Get the Leaflet layer for use in layer controls
getLayer() {
return this.scratchLayer;
}
// Check if layer is currently visible on map
isVisible() {
return this.scratchLayer && this.map.hasLayer(this.scratchLayer);
}
// Remove layer from map
remove() {
if (this.scratchLayer && this.map.hasLayer(this.scratchLayer)) {
this.map.removeLayer(this.scratchLayer);
}
}
// Add layer to map
addToMap() {
if (this.scratchLayer) {
this.scratchLayer.addTo(this.map);
}
}
}

View File

@@ -1326,44 +1326,79 @@ export class VisitsManager {
// Create popup content with form and dropdown
const defaultName = visit.name;
const popupContent = `
<div class="p-3">
<div class="mb-3">
<div class="text-sm mb-1">
<div class="p-4 bg-base-100 text-base-content rounded-lg shadow-lg">
<div class="mb-4">
<div class="text-sm mb-2 text-base-content/80 font-medium">
${dateTimeDisplay.trim()}
</div>
<div>
<span class="text-sm text-gray-500">
Duration: ${durationText},
</span>
<span class="text-sm mb-1 ${statusColorClass} font-semibold">
status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
</span>
<span>${visit.place.latitude}, ${visit.place.longitude}</span>
<div class="space-y-1">
<div class="text-sm text-base-content/60">
Duration: ${durationText}
</div>
<div class="text-sm ${statusColorClass} font-semibold">
Status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
</div>
<div class="text-xs text-base-content/50 font-mono">
${visit.place.latitude}, ${visit.place.longitude}
</div>
</div>
</div>
<form class="visit-name-form" data-visit-id="${visit.id}">
<form class="visit-name-form space-y-3" data-visit-id="${visit.id}">
<div class="form-control">
<label class="label">
<span class="label-text text-sm font-medium">Visit Name</span>
</label>
<input type="text"
class="input input-bordered input-sm w-full text-neutral-content"
class="input input-bordered input-sm w-full bg-base-200 text-base-content placeholder:text-base-content/50"
value="${defaultName}"
placeholder="Enter visit name">
</div>
<div class="form-control mt-2">
<select class="select text-neutral-content select-bordered select-sm w-full h-fit" name="place">
${possiblePlaces.map(place => `
<div class="form-control">
<label class="label">
<span class="label-text text-sm font-medium">Location</span>
</label>
<select class="select select-bordered select-sm text-xs w-full bg-base-200 text-base-content" name="place">
${possiblePlaces.length > 0 ? possiblePlaces.map(place => `
<option value="${place.id}" ${place.id === visit.place.id ? 'selected' : ''}>
${place.name}
</option>
`).join('')}
`).join('') : `
<option value="${visit.place.id}" selected>
${visit.place.name || 'Current Location'}
</option>
`}
</select>
</div>
<div class="flex gap-2 mt-2">
<button type="submit" class="btn btn-xs btn-primary">Save</button>
<div class="flex gap-2 mt-4 pt-2 border-t border-base-300">
<button type="submit" class="btn btn-sm btn-primary flex-1">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Save
</button>
${visit.status !== 'confirmed' ? `
<button type="button" class="btn btn-xs btn-success confirm-visit" data-id="${visit.id}">Confirm</button>
<button type="button" class="btn btn-xs btn-error decline-visit" data-id="${visit.id}">Decline</button>
<button type="button" class="btn btn-sm btn-success confirm-visit" data-id="${visit.id}">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"></path>
</svg>
Confirm
</button>
<button type="button" class="btn btn-sm btn-error decline-visit" data-id="${visit.id}">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Decline
</button>
` : ''}
</div>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline btn-error w-full delete-visit" data-id="${visit.id}">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Delete Visit
</button>
</div>
</form>
</div>
`;
@@ -1374,8 +1409,9 @@ export class VisitsManager {
closeOnClick: true,
autoClose: true,
closeOnEscapeKey: true,
maxWidth: 450, // Set maximum width
minWidth: 300 // Set minimum width
maxWidth: 420, // Set maximum width
minWidth: 320, // Set minimum width
className: 'visit-popup' // Add custom class for additional styling
})
.setLatLng([visit.place.latitude, visit.place.longitude])
.setContent(popupContent);
@@ -1407,6 +1443,12 @@ export class VisitsManager {
const newName = event.target.querySelector('input').value;
const selectedPlaceId = event.target.querySelector('select[name="place"]').value;
// Validate that we have a valid place_id
if (!selectedPlaceId || selectedPlaceId === '') {
showFlashMessage('error', 'Please select a valid location');
return;
}
// Get the selected place name from the dropdown
const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`);
const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : '';
@@ -1473,9 +1515,11 @@ export class VisitsManager {
// Add event listeners for confirm and decline buttons
const confirmBtn = form.querySelector('.confirm-visit');
const declineBtn = form.querySelector('.decline-visit');
const deleteBtn = form.querySelector('.delete-visit');
confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed'));
declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined'));
deleteBtn?.addEventListener('click', (event) => this.handleDeleteVisit(event, visit.id));
}
}
@@ -1517,6 +1561,51 @@ export class VisitsManager {
}
}
/**
* Handles deletion of a visit with confirmation
* @param {Event} event - The click event
* @param {string} visitId - The visit ID to delete
*/
async handleDeleteVisit(event, visitId) {
event.preventDefault();
event.stopPropagation();
// Show confirmation dialog
const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.');
if (!confirmDelete) {
return;
}
try {
const response = await fetch(`/api/v1/visits/${visitId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
}
});
if (response.ok) {
// Close the popup
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
}
// Refresh the visits list
this.fetchAndDisplayVisits();
showFlashMessage('notice', 'Visit deleted successfully');
} else {
const errorData = await response.json();
const errorMessage = errorData.error || 'Failed to delete visit';
showFlashMessage('error', errorMessage);
}
} catch (error) {
console.error('Error deleting visit:', error);
showFlashMessage('error', 'Failed to delete visit');
}
}
/**
* Truncates text to a specified length and adds ellipsis if needed
* @param {string} text - The text to truncate

View File

@@ -15,3 +15,42 @@
.merge-visits-button {
margin: 8px 0;
}
/* Visit popup styling */
.visit-popup .leaflet-popup-content-wrapper {
border-radius: 0.5rem;
border: none;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 0;
overflow: hidden;
}
.visit-popup .leaflet-popup-content {
margin: 0;
line-height: 1.5;
}
.visit-popup .leaflet-popup-tip {
border-top-color: hsl(var(--b1));
}
.visit-popup .leaflet-popup-close-button {
color: hsl(var(--bc)) !important;
font-size: 18px !important;
font-weight: bold !important;
top: 8px !important;
right: 8px !important;
width: 24px !important;
height: 24px !important;
text-align: center !important;
line-height: 24px !important;
background: hsl(var(--b2)) !important;
border-radius: 50% !important;
border: none !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
}
.visit-popup .leaflet-popup-close-button:hover {
background: hsl(var(--b3)) !important;
color: hsl(var(--bc)) !important;
}

View File

@@ -18,7 +18,7 @@ class BulkVisitsSuggestingJob < ApplicationJob
users.active.find_each do |user|
next unless user.safe_settings.visits_suggestions_enabled?
next if user.tracked_points.empty?
next unless user.points_count.positive?
schedule_chunked_jobs(user, time_chunks)
end

View File

@@ -7,7 +7,7 @@ class DataMigrations::MigratePointsLatlonJob < ApplicationJob
user = User.find(user_id)
# rubocop:disable Rails/SkipsModelValidations
user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)')
user.points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)')
# rubocop:enable Rails/SkipsModelValidations
end
end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
class DataMigrations::PrefillPointsCounterCacheJob < ApplicationJob
queue_as :data_migrations
def perform(user_id = nil)
if user_id
prefill_counter_for_user(user_id)
else
User.find_each(batch_size: 100) do |user|
prefill_counter_for_user(user.id)
end
end
end
private
def prefill_counter_for_user(user_id)
User.reset_counters(user_id, :points)
rescue ActiveRecord::RecordNotFound
Rails.logger.warn "User #{user_id} not found, skipping counter cache update"
end
end

View File

@@ -23,9 +23,9 @@ class Tracks::CleanupJob < ApplicationJob
private
def users_with_old_untracked_points(older_than)
User.active.joins(:tracked_points)
.where(tracked_points: { track_id: nil, timestamp: ..older_than.to_i })
.having('COUNT(tracked_points.id) >= 2') # Only users with enough points for tracks
User.active.joins(:points)
.where(points: { track_id: nil, timestamp: ..older_than.to_i })
.having('COUNT(points.id) >= 2') # Only users with enough points for tracks
.group(:id)
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
class Users::MailerSendingJob < ApplicationJob
queue_as :mailers
def perform(user_id, email_type, **options)
user = User.find(user_id)
if trial_related_email?(email_type) && user.active?
Rails.logger.info "Skipping #{email_type} email for user #{user_id} - user is already subscribed"
return
end
params = { user: user }.merge(options)
UsersMailer.with(params).public_send(email_type).deliver_later
end
private
def trial_related_email?(email_type)
%w[trial_expires_soon trial_expired].include?(email_type.to_s)
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class Users::TrialWebhookJob < ApplicationJob
queue_as :default
def perform(user_id)
user = User.find(user_id)
payload = {
user_id: user.id,
email: user.email,
active_until: user.active_until,
status: user.status,
action: 'create_user'
}
token = Subscription::EncodeJwtToken.new(payload, ENV['JWT_SECRET_KEY']).call
request_url = "#{ENV['MANAGER_URL']}/api/v1/users"
headers = {
'Content-Type' => 'application/json',
'Accept' => 'application/json'
}
HTTParty.post(request_url, headers: headers, body: { token: token }.to_json)
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
class UsersMailer < ApplicationMailer
def welcome
@user = params[:user]
mail(to: @user.email, subject: 'Welcome to Dawarich!')
end
def explore_features
@user = params[:user]
mail(to: @user.email, subject: 'Explore Dawarich features!')
end
def trial_expires_soon
@user = params[:user]
mail(to: @user.email, subject: '⚠️ Your Dawarich trial expires in 2 days')
end
def trial_expired
@user = params[:user]
mail(to: @user.email, subject: '💔 Your Dawarich trial expired')
end
end

View File

@@ -10,6 +10,7 @@ module Calculateable
def calculate_distance
calculated_distance_meters = calculate_distance_from_coordinates
self.distance = convert_distance_for_storage(calculated_distance_meters)
end

View File

@@ -13,6 +13,7 @@ class Import < ApplicationRecord
after_commit :remove_attached_file, on: :destroy
validates :name, presence: true, uniqueness: { scope: :user_id }
validate :file_size_within_limit, if: -> { user.trial? }
enum :status, { created: 0, processing: 1, completed: 2, failed: 3 }
@@ -20,7 +21,7 @@ class Import < ApplicationRecord
google_semantic_history: 0, owntracks: 1, google_records: 2,
google_phone_takeout: 3, gpx: 4, immich_api: 5, geojson: 6, photoprism_api: 7,
user_data_archive: 8
}
}, allow_nil: true
def process!
if user_data_archive?
@@ -58,4 +59,12 @@ class Import < ApplicationRecord
def remove_attached_file
file.purge_later
end
def file_size_within_limit
return unless file.attached?
if file.blob.byte_size > 11.megabytes
errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.')
end
end
end

View File

@@ -6,7 +6,7 @@ class Point < ApplicationRecord
belongs_to :import, optional: true, counter_cache: true
belongs_to :visit, optional: true
belongs_to :user
belongs_to :user, counter_cache: true
belongs_to :country, optional: true
belongs_to :track, optional: true

View File

@@ -24,7 +24,7 @@ class Stat < ApplicationRecord
end
def points
user.tracked_points
user.points
.without_raw_data
.where(timestamp: timespan)
.order(timestamp: :asc)

View File

@@ -18,13 +18,7 @@ class Trip < ApplicationRecord
end
def points
user.tracked_points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
end
def countries
return points.pluck(:country).uniq.compact if DawarichSettings.store_geodata?
visited_countries
user.points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
end
def photo_previews
@@ -35,13 +29,8 @@ class Trip < ApplicationRecord
@photo_sources ||= photos.map { _1[:source] }.uniq
end
def calculate_countries
countries =
Country.where(id: points.pluck(:country_id).compact.uniq).pluck(:name)
self.visited_countries = countries
self.visited_countries = points.pluck(:country_name).uniq.compact
end
private

View File

@@ -1,23 +1,24 @@
# frozen_string_literal: true
class User < ApplicationRecord
class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable, :trackable
has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :points, dependent: :destroy, counter_cache: true
has_many :imports, dependent: :destroy
has_many :stats, dependent: :destroy
has_many :exports, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :areas, dependent: :destroy
has_many :visits, dependent: :destroy
has_many :points, through: :imports
has_many :places, through: :visits
has_many :trips, dependent: :destroy
has_many :tracks, dependent: :destroy
after_create :create_api_key
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
after_commit :start_trial, on: :create, if: -> { !DawarichSettings.self_hosted? }
before_save :sanitize_input
validates :email, presence: true
@@ -25,15 +26,16 @@ class User < ApplicationRecord
validates :reset_password_token, uniqueness: true, allow_nil: true
attribute :admin, :boolean, default: false
attribute :points_count, :integer, default: 0
enum :status, { inactive: 0, active: 1 }
enum :status, { inactive: 0, active: 1, trial: 2 }
def safe_settings
Users::SafeSettings.new(settings)
end
def countries_visited
tracked_points
points
.where.not(country_name: [nil, ''])
.distinct
.pluck(:country_name)
@@ -41,7 +43,7 @@ class User < ApplicationRecord
end
def cities_visited
tracked_points.where.not(city: [nil, '']).distinct.pluck(:city).compact
points.where.not(city: [nil, '']).distinct.pluck(:city).compact
end
def total_distance
@@ -58,11 +60,11 @@ class User < ApplicationRecord
end
def total_reverse_geocoded_points
tracked_points.where.not(reverse_geocoded_at: nil).count
points.where.not(reverse_geocoded_at: nil).count
end
def total_reverse_geocoded_points_without_data
tracked_points.where(geodata: {}).count
points.where(geodata: {}).count
end
def immich_integration_configured?
@@ -96,7 +98,7 @@ class User < ApplicationRecord
end
def can_subscribe?
(active_until.nil? || active_until&.past?) && !DawarichSettings.self_hosted?
(trial? || !active_until&.future?) && !DawarichSettings.self_hosted?
end
def generate_subscription_token
@@ -115,6 +117,10 @@ class User < ApplicationRecord
Users::ExportDataJob.perform_later(id)
end
def trial_state?
(points_count || 0).zero? && trial?
end
private
def create_api_key
@@ -124,7 +130,6 @@ class User < ApplicationRecord
end
def activate
# TODO: Remove the `status` column in the future.
update(status: :active, active_until: 1000.years.from_now)
end
@@ -133,4 +138,18 @@ class User < ApplicationRecord
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
end
def start_trial
update(status: :trial, active_until: 7.days.from_now)
schedule_welcome_emails
Users::TrialWebhookJob.perform_later(id)
end
def schedule_welcome_emails
Users::MailerSendingJob.perform_later(id, 'welcome')
Users::MailerSendingJob.set(wait: 2.days).perform_later(id, 'explore_features')
Users::MailerSendingJob.set(wait: 5.days).perform_later(id, 'trial_expires_soon')
Users::MailerSendingJob.set(wait: 7.days).perform_later(id, 'trial_expired')
end
end

View File

@@ -10,6 +10,8 @@ class Visit < ApplicationRecord
validates :started_at, :ended_at, :duration, :name, :status, presence: true
validates :ended_at, comparison: { greater_than: :started_at }
enum :status, { suggested: 0, confirmed: 1, declined: 2 }
def coordinates

View File

@@ -11,13 +11,13 @@ class ImportPolicy < ApplicationPolicy
user.present? && record.user == user
end
# Users can create new imports if they are active
# Users can create new imports if they are active or trial
def new?
create?
end
def create?
user.present? && user.active?
user.present? && (user.active? || user.trial?)
end
# Users can only edit their own imports

View File

@@ -68,6 +68,8 @@ class Api::PhotoSerializer
photo.dig('exifInfo', 'orientation') == '6' ? 'portrait' : 'landscape'
when 'photoprism'
photo['Portrait'] ? 'portrait' : 'landscape'
else
'landscape' # default orientation for nil or unknown source
end
end
end

View File

@@ -10,7 +10,7 @@ class StatsSerializer
def call
{
totalDistanceKm: total_distance_km,
totalPointsTracked: user.tracked_points.count,
totalPointsTracked: user.points_count,
totalReverseGeocodedPoints: reverse_geocoded_points,
totalCountriesVisited: user.countries_visited.count,
totalCitiesVisited: user.cities_visited.count,
@@ -27,7 +27,7 @@ class StatsSerializer
end
def reverse_geocoded_points
user.tracked_points.reverse_geocoded.count
user.points.reverse_geocoded.count
end
def yearly_stats

View File

@@ -35,7 +35,7 @@ class Exports::Create
def time_framed_points
user
.tracked_points
.points
.where(timestamp: start_at.to_i..end_at.to_i)
.order(timestamp: :asc)
end

View File

@@ -2,19 +2,19 @@
class Geojson::Importer
include Imports::Broadcaster
include Imports::FileLoader
include PointValidation
attr_reader :import, :user_id
attr_reader :import, :user_id, :file_path
def initialize(import, user_id)
def initialize(import, user_id, file_path = nil)
@import = import
@user_id = user_id
@file_path = file_path
end
def call
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
json = Oj.load(file_content)
json = load_json_data
data = Geojson::Params.new(json).call
data.each.with_index(1) do |point, index|

View File

@@ -2,12 +2,14 @@
class GoogleMaps::PhoneTakeoutImporter
include Imports::Broadcaster
include Imports::FileLoader
attr_reader :import, :user_id
attr_reader :import, :user_id, :file_path
def initialize(import, user_id)
def initialize(import, user_id, file_path = nil)
@import = import
@user_id = user_id
@file_path = file_path
end
def call
@@ -46,9 +48,7 @@ class GoogleMaps::PhoneTakeoutImporter
raw_signals = []
raw_array = []
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
json = Oj.load(file_content)
json = load_json_data
if json.is_a?(Array)
raw_array = parse_raw_array(json)

View File

@@ -4,11 +4,14 @@
# via the UI, vs the CLI, which uses the `GoogleMaps::RecordsImporter` class.
class GoogleMaps::RecordsStorageImporter
include Imports::FileLoader
BATCH_SIZE = 1000
def initialize(import, user_id)
def initialize(import, user_id, file_path = nil)
@import = import
@user = User.find_by(id: user_id)
@file_path = file_path
end
def call
@@ -20,21 +23,16 @@ class GoogleMaps::RecordsStorageImporter
private
attr_reader :import, :user
attr_reader :import, :user, :file_path
def process_file_in_batches
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
locations = parse_file(file_content)
parsed_file = load_json_data
return unless parsed_file.is_a?(Hash) && parsed_file['locations']
locations = parsed_file['locations']
process_locations_in_batches(locations) if locations.present?
end
def parse_file(file_content)
parsed_file = Oj.load(file_content, mode: :compat)
return nil unless parsed_file.is_a?(Hash) && parsed_file['locations']
parsed_file['locations']
end
def process_locations_in_batches(locations)
batch = []
index = 0

View File

@@ -2,13 +2,15 @@
class GoogleMaps::SemanticHistoryImporter
include Imports::Broadcaster
include Imports::FileLoader
BATCH_SIZE = 1000
attr_reader :import, :user_id
attr_reader :import, :user_id, :file_path
def initialize(import, user_id)
def initialize(import, user_id, file_path = nil)
@import = import
@user_id = user_id
@file_path = file_path
@current_index = 0
end
@@ -61,8 +63,7 @@ class GoogleMaps::SemanticHistoryImporter
end
def points_data
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
json = Oj.load(file_content)
json = load_json_data
json['timelineObjects'].flat_map do |timeline_object|
parse_timeline_object(timeline_object)

View File

@@ -4,16 +4,18 @@ require 'rexml/document'
class Gpx::TrackImporter
include Imports::Broadcaster
include Imports::FileLoader
attr_reader :import, :user_id
attr_reader :import, :user_id, :file_path
def initialize(import, user_id)
def initialize(import, user_id, file_path = nil)
@import = import
@user_id = user_id
@file_path = file_path
end
def call
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
file_content = load_file_content
json = Hash.from_xml(file_content)
tracks = json['gpx']['trk']

View File

@@ -14,17 +14,33 @@ class Imports::Create
import.update!(status: :processing)
broadcast_status_update
importer(import.source).new(import, user.id).call
temp_file_path = Imports::SecureFileDownloader.new(import.file).download_to_temp_file
source = if import.source.nil? || should_detect_source?
detect_source_from_file(temp_file_path)
else
import.source
end
import.update!(source: source)
importer(source).new(import, user.id, temp_file_path).call
schedule_stats_creating(user.id)
schedule_visit_suggesting(user.id, import)
update_import_points_count(import)
User.reset_counters(user.id, :points)
rescue StandardError => e
import.update!(status: :failed)
broadcast_status_update
ExceptionReporter.call(e, 'Import failed')
create_import_failed_notification(import, user, e)
ensure
if temp_file_path && File.exist?(temp_file_path)
File.unlink(temp_file_path)
end
if import.processing?
import.update!(status: :completed)
broadcast_status_update
@@ -34,7 +50,9 @@ class Imports::Create
private
def importer(source)
case source
raise ArgumentError, 'Import source cannot be nil' if source.nil?
case source.to_s
when 'google_semantic_history' then GoogleMaps::SemanticHistoryImporter
when 'google_phone_takeout' then GoogleMaps::PhoneTakeoutImporter
when 'google_records' then GoogleMaps::RecordsStorageImporter
@@ -42,6 +60,8 @@ class Imports::Create
when 'gpx' then Gpx::TrackImporter
when 'geojson' then Geojson::Importer
when 'immich_api', 'photoprism_api' then Photos::Importer
else
raise ArgumentError, "Unsupported source: #{source}"
end
end
@@ -56,7 +76,12 @@ class Imports::Create
end
def schedule_visit_suggesting(user_id, import)
return unless user.safe_settings.visits_suggestions_enabled?
points = import.points.order(:timestamp)
return if points.none?
start_at = Time.zone.at(points.first.timestamp)
end_at = Time.zone.at(points.last.timestamp)
@@ -74,6 +99,17 @@ class Imports::Create
).call
end
def should_detect_source?
# Don't override API-based sources that can't be reliably detected
!%w[immich_api photoprism_api].include?(import.source)
end
def detect_source_from_file(temp_file_path)
detector = Imports::SourceDetector.new_from_file_header(temp_file_path)
detector.detect_source!
end
def import_failed_message(import, error)
if DawarichSettings.self_hosted?
"Import \"#{import.name}\" failed: #{error.message}, stacktrace: #{error.backtrace.join("\n")}"

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Imports
module FileLoader
extend ActiveSupport::Concern
private
def load_json_data
if file_path && File.exist?(file_path)
Oj.load_file(file_path, mode: :compat)
else
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
Oj.load(file_content, mode: :compat)
end
end
def load_file_content
if file_path && File.exist?(file_path)
File.read(file_path)
else
Imports::SecureFileDownloader.new(import.file).download_with_verification
end
end
end
end

View File

@@ -9,6 +9,63 @@ class Imports::SecureFileDownloader
end
def download_with_verification
file_content = download_to_string
verify_file_integrity(file_content)
file_content
end
def download_to_temp_file
retries = 0
temp_file = nil
begin
Timeout.timeout(DOWNLOAD_TIMEOUT) do
temp_file = create_temp_file
# Download directly to temp file
storage_attachment.download do |chunk|
temp_file.write(chunk)
end
temp_file.rewind
# If file is empty, try alternative download method
if temp_file.size == 0
Rails.logger.warn('No content received from block download, trying alternative method')
temp_file.write(storage_attachment.blob.download)
temp_file.rewind
end
end
rescue Timeout::Error => e
retries += 1
if retries <= MAX_RETRIES
Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}")
cleanup_temp_file(temp_file)
retry
else
Rails.logger.error("Download failed after #{MAX_RETRIES} attempts")
cleanup_temp_file(temp_file)
raise
end
rescue StandardError => e
Rails.logger.error("Download error: #{e.message}")
cleanup_temp_file(temp_file)
raise
end
raise 'Download completed but no content was received' if temp_file.size == 0
verify_temp_file_integrity(temp_file)
temp_file.path
ensure
# Keep temp file open so it can be read by other processes
# Caller is responsible for cleanup
end
private
attr_reader :storage_attachment
def download_to_string
retries = 0
file_content = nil
@@ -51,13 +108,23 @@ class Imports::SecureFileDownloader
raise 'Download completed but no content was received' if file_content.nil? || file_content.empty?
verify_file_integrity(file_content)
file_content
end
private
def create_temp_file
extension = File.extname(storage_attachment.filename.to_s)
basename = File.basename(storage_attachment.filename.to_s, extension)
Tempfile.new(["#{basename}_#{Time.now.to_i}", extension], binmode: true)
end
attr_reader :storage_attachment
def cleanup_temp_file(temp_file)
return unless temp_file
temp_file.close unless temp_file.closed?
temp_file.unlink if File.exist?(temp_file.path)
rescue StandardError => e
Rails.logger.warn("Failed to cleanup temp file: #{e.message}")
end
def verify_file_integrity(file_content)
return if file_content.nil? || file_content.empty?
@@ -78,4 +145,26 @@ class Imports::SecureFileDownloader
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
end
def verify_temp_file_integrity(temp_file)
return if temp_file.nil? || temp_file.size == 0
# Verify file size
expected_size = storage_attachment.blob.byte_size
actual_size = temp_file.size
if expected_size != actual_size
raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes"
end
# Verify checksum
expected_checksum = storage_attachment.blob.checksum
temp_file.rewind
actual_checksum = Base64.strict_encode64(Digest::MD5.digest(temp_file.read))
temp_file.rewind
return unless expected_checksum != actual_checksum
raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
end
end

View File

@@ -0,0 +1,235 @@
# frozen_string_literal: true
class Imports::SourceDetector
class UnknownSourceError < StandardError; end
DETECTION_RULES = {
google_semantic_history: {
required_keys: ['timelineObjects'],
nested_patterns: [
['timelineObjects', 0, 'activitySegment'],
['timelineObjects', 0, 'placeVisit']
]
},
google_records: {
required_keys: ['locations'],
nested_patterns: [
['locations', 0, 'latitudeE7'],
['locations', 0, 'longitudeE7']
]
},
google_phone_takeout: {
alternative_patterns: [
# Pattern 1: Object with semanticSegments
{
required_keys: ['semanticSegments'],
nested_patterns: [['semanticSegments', 0, 'startTime']]
},
# Pattern 2: Object with rawSignals
{
required_keys: ['rawSignals']
},
# Pattern 3: Array format with visit/activity objects
{
structure: :array,
nested_patterns: [
[0, 'visit', 'topCandidate', 'placeLocation'],
[0, 'activity']
]
}
]
},
geojson: {
required_keys: ['type', 'features'],
required_values: { 'type' => 'FeatureCollection' },
nested_patterns: [
['features', 0, 'type'],
['features', 0, 'geometry'],
['features', 0, 'properties']
]
},
owntracks: {
structure: :rec_file_lines,
line_pattern: /"_type":"location"/
}
}.freeze
def initialize(file_content, filename = nil, file_path = nil)
@file_content = file_content
@filename = filename
@file_path = file_path
end
def self.new_from_file_header(file_path)
filename = File.basename(file_path)
# For detection, read only first 2KB to optimize performance
header_content = File.open(file_path, 'rb') { |f| f.read(2048) }
new(header_content, filename, file_path)
end
def detect_source
return :gpx if gpx_file?
return :owntracks if owntracks_file?
json_data = parse_json
return nil unless json_data
DETECTION_RULES.each do |format, rules|
next if format == :owntracks # Already handled above
if matches_format?(json_data, rules)
return format
end
end
nil
end
def detect_source!
format = detect_source
raise UnknownSourceError, 'Unable to detect file format' unless format
format
end
private
attr_reader :file_content, :filename, :file_path
def gpx_file?
return false unless filename
# Must have .gpx extension AND contain GPX XML structure
return false unless filename.downcase.end_with?('.gpx')
# Check content for GPX structure
content_to_check = if file_path && File.exist?(file_path)
# Read first 1KB for GPX detection
File.open(file_path, 'rb') { |f| f.read(1024) }
else
file_content
end
content_to_check.strip.start_with?('<?xml') && content_to_check.include?('<gpx')
end
def owntracks_file?
return false unless filename
# Check for .rec extension first (fastest check)
return true if filename.downcase.end_with?('.rec')
# Check for specific OwnTracks line format in content
content_to_check = if file_path && File.exist?(file_path)
# For OwnTracks, read first few lines only
File.open(file_path, 'r') { |f| f.read(2048) }
else
file_content
end
content_to_check.lines.any? { |line| line.include?('"_type":"location"') }
end
def parse_json
# If we have a file path, use streaming for better memory efficiency
if file_path && File.exist?(file_path)
Oj.load_file(file_path, mode: :compat)
else
Oj.load(file_content, mode: :compat)
end
rescue Oj::ParseError, JSON::ParserError
# If full file parsing fails but we have a file path, try with just the header
if file_path && file_content.length < 2048
begin
File.open(file_path, 'rb') do |f|
partial_content = f.read(4096) # Try a bit more content
Oj.load(partial_content, mode: :compat)
end
rescue Oj::ParseError, JSON::ParserError
nil
end
else
nil
end
end
def matches_format?(json_data, rules)
# Handle alternative patterns (for google_phone_takeout)
if rules[:alternative_patterns]
return rules[:alternative_patterns].any? { |pattern| matches_pattern?(json_data, pattern) }
end
matches_pattern?(json_data, rules)
end
def matches_pattern?(json_data, pattern)
# Check structure requirements
return false unless structure_matches?(json_data, pattern[:structure])
# Check required keys
if pattern[:required_keys]
return false unless has_required_keys?(json_data, pattern[:required_keys])
end
# Check required values
if pattern[:required_values]
return false unless has_required_values?(json_data, pattern[:required_values])
end
# Check nested patterns
if pattern[:nested_patterns]
return false unless has_nested_patterns?(json_data, pattern[:nested_patterns])
end
true
end
def structure_matches?(json_data, required_structure)
case required_structure
when :array
json_data.is_a?(Array)
when nil
true # No specific structure required
else
true # Default to no restriction
end
end
def has_required_keys?(json_data, keys)
return false unless json_data.is_a?(Hash)
keys.all? { |key| json_data.key?(key) }
end
def has_required_values?(json_data, values)
return false unless json_data.is_a?(Hash)
values.all? { |key, expected_value| json_data[key] == expected_value }
end
def has_nested_patterns?(json_data, patterns)
patterns.any? { |pattern| nested_key_exists?(json_data, pattern) }
end
def nested_key_exists?(data, key_path)
current = data
key_path.each do |key|
return false unless current
if current.is_a?(Array)
return false if key >= current.length
current = current[key]
elsif current.is_a?(Hash)
return false unless current.key?(key)
current = current[key]
else
return false
end
end
!current.nil?
end
end

View File

@@ -70,12 +70,14 @@ class Imports::Watcher
end
def mime_type(source)
case source.to_sym
case source&.to_sym
when :gpx then 'application/xml'
when :json, :geojson, :google_phone_takeout, :google_records, :google_semantic_history
'application/json'
when :owntracks
'application/octet-stream'
when nil
'application/octet-stream' # fallback MIME type for nil source
else
raise UnsupportedSourceError, "Unsupported source: #{source}"
end

View File

@@ -14,9 +14,9 @@ class Jobs::Create
points =
case job_name
when 'start_reverse_geocoding'
user.tracked_points
user.points
when 'continue_reverse_geocoding'
user.tracked_points.not_reverse_geocoded
user.points.not_reverse_geocoded
else
raise InvalidJobName, 'Invalid job name'
end

View File

@@ -2,16 +2,18 @@
class OwnTracks::Importer
include Imports::Broadcaster
include Imports::FileLoader
attr_reader :import, :user_id
attr_reader :import, :user_id, :file_path
def initialize(import, user_id)
def initialize(import, user_id, file_path = nil)
@import = import
@user_id = user_id
@file_path = file_path
end
def call
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
file_content = load_file_content
parsed_data = OwnTracks::RecParser.new(file_content).call
points_data = parsed_data.map do |point|

View File

@@ -2,17 +2,18 @@
class Photos::Importer
include Imports::Broadcaster
include Imports::FileLoader
include PointValidation
attr_reader :import, :user_id
attr_reader :import, :user_id, :file_path
def initialize(import, user_id)
def initialize(import, user_id, file_path = nil)
@import = import
@user_id = user_id
@file_path = file_path
end
def call
file_content = Imports::SecureFileDownloader.new(import.file).download_with_verification
json = Oj.load(file_content)
json = load_json_data
json.each.with_index(1) { |point, index| create_point(point, index) }
end

View File

@@ -10,7 +10,8 @@ class Photos::Thumbnail
end
def call
raise unsupported_source_error unless SUPPORTED_SOURCES.include?(source)
raise ArgumentError, 'Photo source cannot be nil' if source.nil?
unsupported_source_error unless SUPPORTED_SOURCES.include?(source)
HTTParty.get(request_url, headers: headers)
end

View File

@@ -9,7 +9,7 @@ class PointsLimitExceeded
return false if DawarichSettings.self_hosted?
Rails.cache.fetch(cache_key, expires_in: 1.day) do
@user.tracked_points.count >= points_limit
@user.points_count >= points_limit
end
end

View File

@@ -47,7 +47,7 @@ class Stats::CalculateMonth
return @points if defined?(@points)
@points = user
.tracked_points
.points
.without_raw_data
.where(timestamp: start_timestamp..end_timestamp)
.select(:lonlat, :timestamp)
@@ -60,7 +60,7 @@ class Stats::CalculateMonth
def toponyms
toponym_points = user
.tracked_points
.points
.without_raw_data
.where(timestamp: start_timestamp..end_timestamp)
.select(:city, :country_name)

View File

@@ -0,0 +1,16 @@
# frozen_string_literal: true
class Subscription::EncodeJwtToken
def initialize(payload, secret_key)
@payload = payload
@secret_key = secret_key
end
def call
JWT.encode(
@payload,
@secret_key,
'HS256'
)
end
end

View File

@@ -86,7 +86,7 @@ class Tracks::Generator
end
def load_bulk_points
scope = user.tracked_points.order(:timestamp)
scope = user.points.order(:timestamp)
scope = scope.where(timestamp: timestamp_range) if time_range_defined?
scope
@@ -95,7 +95,7 @@ class Tracks::Generator
def load_incremental_points
# For incremental mode, we process untracked points
# If end_at is specified, only process points up to that time
scope = user.tracked_points.where(track_id: nil).order(:timestamp)
scope = user.points.where(track_id: nil).order(:timestamp)
scope = scope.where(timestamp: ..end_at.to_i) if end_at.present?
scope
@@ -104,7 +104,7 @@ class Tracks::Generator
def load_daily_points
day_range = daily_time_range
user.tracked_points.where(timestamp: day_range).order(:timestamp)
user.points.where(timestamp: day_range).order(:timestamp)
end
def create_track_from_segment(segment_data)
@@ -195,8 +195,8 @@ class Tracks::Generator
def bulk_timestamp_range
return [start_at.to_i, end_at.to_i] if start_at && end_at
first_point = user.tracked_points.order(:timestamp).first
last_point = user.tracked_points.order(:timestamp).last
first_point = user.points.order(:timestamp).first
last_point = user.points.order(:timestamp).last
[first_point&.timestamp || 0, last_point&.timestamp || Time.current.to_i]
end
@@ -207,7 +207,7 @@ class Tracks::Generator
end
def incremental_timestamp_range
first_point = user.tracked_points.where(track_id: nil).order(:timestamp).first
first_point = user.points.where(track_id: nil).order(:timestamp).first
end_timestamp = end_at ? end_at.to_i : Time.current.to_i
[first_point&.timestamp || 0, end_timestamp]

View File

@@ -50,7 +50,7 @@ class Tracks::IncrementalProcessor
def find_previous_point
@previous_point ||=
user.tracked_points
user.points
.where('timestamp < ?', new_point.timestamp)
.order(:timestamp)
.last

View File

@@ -59,7 +59,8 @@ module Tracks::TrackBuilder
original_path: build_path(points)
)
track.distance = pre_calculated_distance.round
# TODO: Move trips attrs to columns with more precision and range
track.distance = [[pre_calculated_distance.round, 999999.99].min, 0].max
track.duration = calculate_duration(points)
track.avg_speed = calculate_average_speed(track.distance, track.duration)
@@ -99,8 +100,10 @@ module Tracks::TrackBuilder
# Speed in meters per second, then convert to km/h for storage
speed_mps = distance_in_meters.to_f / duration_seconds
speed_kmh = (speed_mps * 3.6).round(2) # m/s to km/h
(speed_mps * 3.6).round(2) # m/s to km/h
# Cap the speed to prevent database precision overflow (max 999999.99)
[speed_kmh, 999999.99].min
end
def calculate_elevation_stats(points)

View File

@@ -331,7 +331,7 @@ class Users::ExportData
trips: user.trips.count,
stats: user.stats.count,
notifications: user.notifications.count,
points: user.tracked_points.count,
points: user.points_count,
visits: user.visits.count,
places: user.places.count
}

View File

@@ -0,0 +1,90 @@
# frozen_string_literal: true
module Visits
class Create
attr_reader :user, :params, :errors, :visit
def initialize(user, params)
@user = user
@params = params.respond_to?(:with_indifferent_access) ? params.with_indifferent_access : params
@visit = nil
@errors = nil
end
def call
ActiveRecord::Base.transaction do
place = find_or_create_place
return false unless place
visit = create_visit(place)
visit
end
rescue ActiveRecord::RecordInvalid => e
ExceptionReporter.call(e, "Failed to create visit: #{e.message}")
@errors = "Failed to create visit: #{e.message}"
false
rescue StandardError => e
ExceptionReporter.call(e, "Failed to create visit: #{e.message}")
@errors = "Failed to create visit: #{e.message}"
false
end
private
def find_or_create_place
existing_place = find_existing_place
return existing_place if existing_place
create_new_place
end
def find_existing_place
Place.joins("JOIN visits ON places.id = visits.place_id")
.where(visits: { user: user })
.where(
"ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)",
params[:longitude].to_f, params[:latitude].to_f, 0.001 # approximately 100 meters
).first
end
def create_new_place
place_name = params[:name]
lat_f = params[:latitude].to_f
lon_f = params[:longitude].to_f
place = Place.create!(
name: place_name,
latitude: lat_f,
longitude: lon_f,
lonlat: "POINT(#{lon_f} #{lat_f})",
source: :manual
)
place
rescue StandardError => e
ExceptionReporter.call(e, "Failed to create place: #{e.message}")
nil
end
def create_visit(place)
started_at = DateTime.parse(params[:started_at])
ended_at = DateTime.parse(params[:ended_at])
duration_minutes = (ended_at - started_at) * 24 * 60
@visit = user.visits.create!(
name: params[:name],
place: place,
started_at: started_at,
ended_at: ended_at,
duration: duration_minutes.to_i,
status: :confirmed
)
@visit
end
end
end

View File

@@ -114,7 +114,7 @@ module Visits
# Look for existing place with this name
existing = Place.where(name: name)
.near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m)
.near([point.lat, point.lon], SIMILARITY_RADIUS, :m)
.first
return existing if existing
@@ -122,9 +122,9 @@ module Visits
# Create new place
place = Place.new(
name: name,
lonlat: "POINT(#{point.longitude} #{point.latitude})",
latitude: point.latitude,
longitude: point.longitude,
lonlat: "POINT(#{point.lon} #{point.lat})",
latitude: point.lat,
longitude: point.lon,
city: properties['city'],
country: properties['country'],
geodata: point.geodata,

View File

@@ -13,7 +13,7 @@ module Visits
@user = user
@start_at = start_at.to_i
@end_at = end_at.to_i
@points = user.tracked_points.not_visited
@points = user.points.not_visited
.order(timestamp: :asc)
.where(timestamp: start_at..end_at)
end

View File

@@ -6,7 +6,7 @@ class Visits::Suggest
def initialize(user, start_at:, end_at:)
@start_at = start_at.to_i
@end_at = end_at.to_i
@points = user.tracked_points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at)
@points = user.points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at)
@user = user
end

View File

@@ -1,6 +1,14 @@
<p class="py-6">
<p class='py-2'>Use this API key to authenticate your requests.</p>
<code><%= current_user.api_key %></code>
<% if ENV['QR_CODE_ENABLED'] == 'true' %>
<p class='py-2'>
Or you can scan it in your Dawarich iOS app:
<%= api_key_qr_code(current_user) %>
</p>
<% end %>
<p class='py-2'>
<p>Docs: <%= link_to "API documentation", '/api-docs', class: 'underline hover:no-underline' %></p>
@@ -20,7 +28,6 @@
<div class='divider'>OR</div>
<h3 class='text-lg font-bold mt-4'>Overland</h3>
<p><code><%= api_v1_overland_batches_url(api_key: current_user.api_key) %></code></p>
</p>
<p class='py-2'>
<%= link_to "Generate new API key", generate_api_key_path, data: { confirm: "Are you sure? This will invalidate the current API key.", turbo_confirm: "Are you sure?", turbo_method: :post }, class: 'btn btn-primary' %>

View File

@@ -1,6 +1,6 @@
<p class="py-6">
<p class='py-2'>
You have used <%= number_with_delimiter(current_user.tracked_points.count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
You have used <%= number_with_delimiter(current_user.points_count) %> points of <%= number_with_delimiter(DawarichSettings::BASIC_PAID_PLAN_LIMIT) %> available.
</p>
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.tracked_points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
<progress class="progress progress-primary w-1/2 h-5" value="<%= current_user.points_count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
</p>

View File

@@ -5,6 +5,12 @@
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold mb-5">Edit your account!</h1>
<%= render 'devise/registrations/api_key' %>
<% if current_user.trial? %>
<p>Your trial period ends at <%= human_datetime current_user.active_until %>.</p>
<p>
<%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success glass' %> to continue using Dawarich after your trial ends.
</p>
<% end %>
<% if !DawarichSettings.self_hosted? %>
<%= render 'devise/registrations/points_usage' %>
<% end %>

View File

@@ -1,70 +1,25 @@
<!-- Supported Formats Info Card -->
<div class="card bg-base-200 w-full max-w-md mb-5 mt-5">
<div class="card-body p-4">
<h3 class="card-title text-sm">Supported Import Formats</h3>
<ul class="text-xs space-y-1">
<li><strong>✅ Google Maps:</strong> Records.json, Semantic History, Phone Takeout (.json)</li>
<li><strong>✅ GPX:</strong> Track files (.gpx)</li>
<li><strong>✅ GeoJSON:</strong> Feature collections (.json)</li>
<li><strong>✅ OwnTracks:</strong> Recorder files (.rec)</li>
</ul>
<div class="text-xs text-gray-500 mt-2">
File format is automatically detected during upload.
</div>
</div>
</div>
<%= form_with model: import, class: "contents", data: {
controller: "direct-upload",
direct_upload_url_value: rails_direct_uploads_url,
direct_upload_user_trial_value: current_user.trial?,
direct_upload_target: "form"
} do |form| %>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Select source</span>
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= form.radio_button :source, :google_semantic_history, class: "radio radio-primary" %>
<span class="label-text">Google Semantic History</span>
</label>
<p class="text-sm mt-2">JSON files from your Takeout/Location History/Semantic Location History/YEAR</p>
</div>
</div>
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= form.radio_button :source, :google_records, class: "radio radio-primary" %>
<span class="label-text">Google Records</span>
</label>
<p class="text-sm mt-2">The Records.json file from your Google Takeout</p>
</div>
</div>
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= form.radio_button :source, :google_phone_takeout, class: "radio radio-primary" %>
<span class="label-text">Google Phone Takeout</span>
</label>
<p class="text-sm mt-2">A JSON file you received after your request for Takeout from your mobile device</p>
</div>
</div>
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= form.radio_button :source, :owntracks, class: "radio radio-primary" %>
<span class="label-text">Owntracks</span>
</label>
<p class="text-sm mt-2">A .REC file you could find in your volumes/owntracks-recorder/store/rec/USER/TOPIC directory</p>
</div>
</div>
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= form.radio_button :source, :geojson, class: "radio radio-primary" %>
<span class="label-text">GeoJSON</span>
</label>
<p class="text-sm mt-2">A valid GeoJSON file. For example, a file, exported from a Dawarich instance</p>
</div>
</div>
<div class="card bordered shadow-lg p-3 hover:shadow-blue-500/50">
<div class="form-control">
<label class="label cursor-pointer space-x-3">
<%= form.radio_button :source, :gpx, class: "radio radio-primary" %>
<span class="label-text">GPX</span>
</label>
<p class="text-sm mt-2">GPX track file</p>
</div>
</div>
</div>
</div>
<label class="form-control w-full max-w-xs my-5">
<div class="label">
<span class="label-text">Select one or multiple files</span>

View File

@@ -36,7 +36,7 @@
</div>
</div>
<div class="overflow-x-auto">
<table class="table overflow-x-auto">
<table class="table table-zebra overflow-x-auto">
<thead>
<tr>
<th>Name</th>
@@ -55,7 +55,8 @@
<% @imports.each do |import| %>
<tr data-import-id="<%= import.id %>"
id="import-<%= import.id %>"
data-points-total="<%= import.processed %>">
data-points-total="<%= import.processed %>"
class="hover">
<td>
<%= link_to import.name, import, class: 'underline hover:no-underline' %>
(<%= import.source %>)
@@ -72,9 +73,9 @@
<td><%= human_datetime(import.created_at) %></td>
<td class="whitespace-nowrap">
<% if import.file.present? %>
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "px-4 py-2 bg-blue-500 text-white rounded-md", download: import.name %>
<%= link_to 'Download', rails_blob_path(import.file, disposition: 'attachment'), class: "btn btn-outline btn-sm btn-info", download: import.name %>
<% end %>
<%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
<%= link_to 'Delete', import, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "btn btn-outline btn-sm btn-error" %>
</td>
</tr>
<% end %>

View File

@@ -31,5 +31,7 @@
</div>
<%= render SELF_HOSTED ? 'shared/footer' : 'shared/legal_footer' %>
</div>
<%= render 'map/onboarding_modal' %>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<% if user_signed_in? %>
<div data-controller="onboarding-modal"
data-onboarding-modal-showable-value="<%= onboarding_modal_showable?(current_user) %>">
<dialog id="getting_started" class="modal" data-onboarding-modal-target="modal">
<div class="modal-box">
<h3 class="text-lg font-bold">Start tracking your location!</h3>
<p class="py-4">
To start tracking your location and putting it on the map, you need to configure your mobile application.
</p>
<p>
To do so, grab the API key from <%= link_to 'here', settings_path, class: 'link' %> and follow the instructions in the <%= link_to 'documentation', 'https://dawarich.app/docs/tutorials/track-your-location?utm_source=app&utm_medium=referral&utm_campaign=onboarding', class: 'link' %>.
</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
</form>
</div>
</div>
</dialog>
</div>
<% end %>

View File

@@ -63,7 +63,7 @@
<div
id='map'
class="w-full z-0"
data-controller="maps points"
data-controller="maps points add-visit"
data-points-target="map"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"

View File

@@ -24,7 +24,7 @@
</div>
</td>
<td>
<%= number_with_delimiter user.tracked_points.count %>
<%= number_with_delimiter user.points_count %>
</td>
<td>
<%= human_datetime(user.created_at) %>

View File

@@ -20,7 +20,9 @@
</details>
</li>
<% if user_signed_in? && current_user.can_subscribe? %>
<li><%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %></li>
<li>
<%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %>
</li>
<% end %>
</ul>
</div>
@@ -71,7 +73,15 @@
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
<% if user_signed_in? %>
<% if current_user.can_subscribe? %>
<li><%= link_to 'Subscribe', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", class: 'btn btn-sm btn-success' %></li>
<div class="join">
<%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %>
<span class="join-item btn btn-sm <%= trial_button_class(current_user) %>">
<span class="tooltip tooltip-bottom" data-tip="Your trial will end in <%= distance_of_time_in_words(current_user.active_until, Time.current) %>"><%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining</span>
</span><span class="join-item btn btn-sm btn-success">
Subscribe
</span>
<% end %>
</div>
<% end %>
<div class="dropdown dropdown-end dropdown-bottom dropdown-hover"
@@ -113,6 +123,9 @@
<details>
<summary>
<%= "#{current_user.email}" %>
<% if onboarding_modal_showable?(current_user) %>
<span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %>
<% if current_user.admin? %>
<span class='tooltip tooltip-bottom' data-tip="You're an admin, Harry!">⭐️</span>
<% end %>
@@ -123,6 +136,14 @@
<% if !DawarichSettings.self_hosted? %>
<li><%= link_to 'Subscription', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" %></li>
<% end %>
<li>
<a onclick="getting_started.showModal()" class="relative">
Get started
<% if onboarding_modal_showable?(current_user) %>
<span class="indicator-item badge badge-secondary badge-xs"></span>
<% end %>
</a>
</li>
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo: false } %></li>
</ul>
@@ -137,4 +158,3 @@
</ul>
</div>
</div>

View File

@@ -15,12 +15,9 @@
<div class="card-body p-4">
<div class="stat-title text-xs">Countries</div>
<div class="stat-value text-lg">
<% if trip.countries.any? %>
<%= trip.countries.join(', ') %>
<% elsif trip.visited_countries.present? %>
<% if trip.visited_countries.any? %>
<%= trip.visited_countries.join(', ') %>
<% else %>
<span class="text-xs">Countries are being calculated...</span>
<span class="loading loading-dots loading-sm"></span>
<% end %>
</div>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #16a34a; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #16a34a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.feature { margin: 15px 0; padding: 15px; background: white; border-left: 4px solid #16a34a; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Explore Dawarich Features</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</p>
<p>You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.</p>
<p>Here are some powerful features you might want to explore:</p>
<div class="feature">
<h3>📊 Statistics & Analytics</h3>
<p>View detailed insights about distances traveled and time spent in different locations.</p>
</div>
<div class="feature">
<h3>🗺️ Interactive Maps</h3>
<p>Visualize your tracks on beautiful maps with different layers and styling options.</p>
</div>
<div class="feature">
<h3>📍 Places & Visits</h3>
<p>Discover the places you've visited and get automatic visit detection for frequently visited locations.</p>
</div>
<div class="feature">
<h3>📤 Data Export</h3>
<p>Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.</p>
</div>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=explore_features&utm_content=continue_exploring" class="cta">Continue Exploring</a>
<p>You have <strong>5 days</strong> left in your trial. Make the most of it!</p>
<p>Best regards,<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,26 @@
Explore Dawarich Features
Hi <%= @user.email %>,
You're now 2 days into your Dawarich trial! We hope you're enjoying tracking your location data.
Here are some powerful features you might want to explore:
📊 Statistics & Analytics
View detailed insights about distances traveled and time spent in different locations.
🗺️ Interactive Maps
Visualize your tracks on beautiful maps with different layers and styling options.
📍 Places & Visits
Discover the places you've visited and get automatic visit detection for frequently visited locations.
📤 Data Export
Export your location data in multiple formats (GPX, GeoJSON) for backup or use with other applications.
Continue exploring: https://my.dawarich.app
You have 5 days left in your trial. Make the most of it!
Best regards,
Evgenii from Dawarich

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #dc2626; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.expired { background: #fee2e2; border: 1px solid #dc2626; padding: 15px; border-radius: 6px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔒 Your Trial Has Expired</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</p>
<div class="expired">
<p><strong>Your 7-day Dawarich trial has ended.</strong></p>
</div>
<p>Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.</p>
<p>Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.</p>
<h3>🔓 Restore full access with a subscription:</h3>
<ul>
<li>Resume location tracking</li>
<li>Access all your historical data</li>
<li>Use travel analytics and insights</li>
<li>Export data in multiple formats</li>
<li>Enjoy beautiful interactive maps</li>
</ul>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expired&utm_content=subscribe_to_continue" class="cta">Subscribe to Continue</a>
<p>Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!</p>
<p>We'd love to have you back as a subscriber.</p>
<p>Best regards,<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
🔒 Your Trial Has Expired
Hi <%= @user.email %>,
Your 7-day Dawarich trial has ended.
Thank you for trying Dawarich! We hope you enjoyed exploring your location data over the past week.
Your trial account is now limited, but your data is safe and secure. To regain full access to all features, please subscribe to continue your journey with Dawarich.
🔓 Restore full access with a subscription:
- Resume location tracking
- Access all your historical data
- Use travel analytics and insights
- Export data in multiple formats
- Enjoy beautiful interactive maps
Subscribe to continue: https://my.dawarich.app
Ready to unlock the full power of location insights? Subscribe now and pick up right where you left off!
We'd love to have you back as a subscriber.
Best regards,
Evgenii from Dawarich

View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f59e0b; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #dc2626; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
.urgent { background: #fef3c7; border: 1px solid #f59e0b; padding: 15px; border-radius: 6px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>⏰ Your Trial Expires Soon</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</p>
<div class="urgent">
<p><strong>⚠️ Important:</strong> Your Dawarich trial expires in just <strong>2 days</strong>!</p>
</div>
<p>We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.</p>
<p>To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.</p>
<h3>✨ What you'll keep with a subscription:</h3>
<ul>
<li>Location tracking and data storage</li>
<li>Travel analytics and insights</li>
<li>Data export in multiple formats</li>
<li>Beautiful interactive maps</li>
<li>Visit detection and places management</li>
</ul>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=trial_expires_soon&utm_content=subscribe_now" class="cta">Subscribe Now</a>
<p>Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!</p>
<p>Questions? Drop us a message at hi@dawarich.app or just reply to this email.</p>
<p>Best regards,<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
⏰ Your Trial Expires Soon
Hi <%= @user.email %>,
⚠️ Important: Your Dawarich trial expires in just 2 days!
We hope you've enjoyed exploring your location data with Dawarich over the past 5 days.
To continue using all of Dawarich's powerful features after your trial ends, you'll need to subscribe to a plan.
✨ What you'll keep with a subscription:
- Location tracking and data storage
- Travel analytics and insights
- Data export in multiple formats
- Beautiful interactive maps
- Visit detection and places management
Subscribe now: https://my.dawarich.app
Don't lose access to your location insights. Subscribe today and continue your journey with Dawarich!
Questions? Drop us a message at hi@dawarich.app
Best regards,
Evgenii from Dawarich

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #2563eb; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; background: #f9fafb; }
.cta { background: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Dawarich!</h1>
</div>
<div class="content">
<p>Hi <%= @user.email %>,</p>
<p>Welcome to Dawarich! We're excited to have you on board.</p>
<p>Your 7-day free trial has started. During this time, you can:</p>
<ul>
<li>Track your location data</li>
<li>View your movement patterns on beautiful maps</li>
<li>Analyze your travel statistics</li>
<li>Export your data in various formats</li>
</ul>
<a href="https://my.dawarich.app?utm_source=email&utm_medium=email&utm_campaign=welcome&utm_content=start_exploring" class="cta">Start Exploring Dawarich</a>
<p>If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.</p>
<p>Happy tracking!<br>
Evgenii from Dawarich</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,18 @@
Welcome to Dawarich!
Hi <%= @user.email %>,
Welcome to Dawarich! We're excited to have you on board.
Your 7-day free trial has started. During this time, you can:
- Track your location data
- View your movement patterns on beautiful maps
- Analyze your travel statistics
- Export your data in various formats
Start exploring Dawarich: https://my.dawarich.app
If you have any questions, feel free to drop us a message at hi@dawarich.app or just reply to this email.
Happy tracking!
Evgenii from Dawarich

View File

@@ -36,5 +36,7 @@ module Dawarich
end
config.active_job.queue_adapter = :sidekiq
config.action_mailer.preview_paths << "#{Rails.root}/spec/mailers/previews"
end
end

View File

@@ -101,7 +101,7 @@ Rails.application.routes.draw do
resources :areas, only: %i[index create update destroy]
resources :points, only: %i[index create update destroy]
resources :visits, only: %i[index update] do
resources :visits, only: %i[index create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member
collection do
post 'merge', to: 'visits#merge'

View File

@@ -4,6 +4,7 @@
- data_migrations
- points
- default
- mailers
- imports
- exports
- stats

View File

@@ -8,7 +8,7 @@ class CreateTracksFromPoints < ActiveRecord::Migration[8.0]
processed_users = 0
User.find_each do |user|
points_count = user.tracked_points.count
points_count = user.points.count
if points_count > 0
puts "Enqueuing track creation for user #{user.id} (#{points_count} points)"

View File

@@ -0,0 +1,12 @@
class AddPointsCountToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :points_count, :integer, default: 0, null: false
# Initialize counter cache for existing users using background job
reversible do |dir|
dir.up do
DataMigrations::PrefillPointsCounterCacheJob.perform_later
end
end
end
end

View File

@@ -0,0 +1,5 @@
class RemoveDefaultFromImportsSource < ActiveRecord::Migration[8.0]
def change
change_column_default :imports, :source, from: 0, to: nil
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_07_28_191359) do
ActiveRecord::Schema[8.0].define(version: 2025_08_23_125940) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@@ -99,7 +99,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
create_table "imports", force: :cascade do |t|
t.string "name", null: false
t.bigint "user_id", null: false
t.integer "source", default: 0
t.integer "source"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "raw_points", default: 0
@@ -230,7 +230,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
t.datetime "end_at", null: false
t.bigint "user_id", null: false
t.geometry "original_path", limit: {srid: 0, type: "line_string"}, null: false
t.integer "distance"
t.decimal "distance", precision: 8, scale: 2
t.float "avg_speed"
t.integer "duration"
t.integer "elevation_gain"
@@ -274,6 +274,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_28_191359) do
t.string "last_sign_in_ip"
t.integer "status", default: 0
t.datetime "active_until"
t.integer "points_count", default: 0, null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end

View File

@@ -4,7 +4,7 @@ FactoryBot.define do
factory :import do
user
sequence(:name) { |n| "owntracks_export_#{n}.json" }
source { Import.sources[:owntracks] }
# source { Import.sources[:owntracks] }
trait :with_points do
after(:create) do |import|

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