Merge pull request #1192 from Freika/dev

0.26.1
This commit is contained in:
Evgenii Burmakin 2025-05-18 11:48:47 +02:00 committed by GitHub
commit f710f24f23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 2601 additions and 3608 deletions

View file

@ -1 +1 @@
0.26.0
0.26.1

View file

@ -28,7 +28,6 @@ services:
APPLICATION_HOSTS: localhost
TIME_ZONE: Europe/London
APPLICATION_PROTOCOL: http
DISTANCE_UNIT: km
PROMETHEUS_EXPORTER_ENABLED: false
PROMETHEUS_EXPORTER_HOST: 0.0.0.0
PROMETHEUS_EXPORTER_PORT: 9394

View file

@ -4,4 +4,3 @@ DATABASE_PASSWORD=password
DATABASE_NAME=dawarich_development
DATABASE_PORT=5432
REDIS_URL=redis://localhost:6379/1
DISTANCE_UNIT='km'

1
.rspec
View file

@ -1 +1,2 @@
--require spec_helper
--profile

View file

@ -4,6 +4,63 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.26.1 - 2025-05-15
## Geodata on demand
This release introduces a new environment variable `STORE_GEODATA` with default value `true` to control whether to store geodata in the database or not. Currently, geodata is being used when:
- Fetching places geodata
- Fetching countries for a trip
- Suggesting place name for a visit
Opting out of storing geodata will make each feature that uses geodata to make a direct request to the geocoding service to calculate required data instead of using existing geodata from the database. Setting `STORE_GEODATA` to `false` can also use you some database space.
If you decide to opt out, you can safely delete your existing geodata from the database:
1. Get into the [console](https://dawarich.app/docs/FAQ/#how-to-enter-dawarich-console)
2. Run the following commands:
```ruby
Point.update_all(geodata: {}) # to remove existing geodata
ActiveRecord::Base.connection.execute("VACUUM FULL") # to free up some space
```
Note, that this will take some time to complete, depending on the number of points you have. This is not a required step.
If you're running your own Photon instance, you can safely set `STORE_GEODATA` to `false`, otherwise it'd be better to keep it enabled, because that way Dawarich will be using existing geodata for its calculations.
Also, after updating to this version, Dawarich will start a huge background job to calculate countries for all your points. Just let it work.
## Added
- Map page now has a button to go to the previous and next day. #296 #631 #904
- Clicking on number of countries and cities in stats cards now opens a modal with a list of countries and cities visited in that year.
## Changed
- Reverse geocoding is now working as on-demand job instead of storing the result in the database. #619
- Stats cards now show the last update time. #733
- Visit card now shows buttons to confirm or decline a visit only if it's not confirmed or declined yet.
- Distance unit is now being stored in the user settings. You can choose between kilometers and miles, default is kilometers. The setting is accessible in the user settings -> Maps -> Distance Unit. You might want to recalculate your stats after changing the unit. #1126
- Fog of war is now being displayed as lines instead of dots. Thanks to @MeijiRestored!
## Fixed
- Fixed a bug with an attempt to write points with same lonlat and timestamp from iOS app. #1170
- Importing GeoJSON files now saves velocity if it was stored in either `velocity` or `speed` property.
- `rake points:migrate_to_lonlat` should work properly now. #1083 #1161
- PostGIS extension is now being enabled only if it's not already enabled. #1186
- Fixed a bug where visits were returning into Suggested state after being confirmed or declined. #848
- If no points are found for a month during stats calculation, stats are now being deleted instead of being left empty. #1066 #406
## Removed
- Removed `DISTANCE_UNIT` constant. It can be safely removed from your environment variables in docker-compose.yml.
# 0.26.0 - 2025-05-08
⚠️ This release includes a breaking change. ⚠️
@ -19,7 +76,6 @@ If you have encountered problems with moving to a PostGIS image while still on P
- Dawarich now uses PostgreSQL 17 with PostGIS 3.5 by default.
# 0.25.10 - 2025-05-08
## Added

View file

@ -30,10 +30,12 @@ gem 'rails', '~> 8.0'
gem 'rexml'
gem 'rgeo'
gem 'rgeo-activerecord'
gem 'rgeo-geojson'
gem 'rswag-api'
gem 'rswag-ui'
gem 'sentry-ruby'
gem 'sentry-rails'
gem 'stackprof'
gem 'sidekiq'
gem 'sidekiq-cron'
gem 'sidekiq-limit_fetch'

View file

@ -105,7 +105,7 @@ GEM
racc
builder (3.3.0)
byebug (12.0.0)
chartkick (5.1.4)
chartkick (5.1.5)
coderay (1.1.3)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
@ -219,6 +219,7 @@ GEM
mini_portile2 (2.8.8)
minitest (5.25.5)
msgpack (1.7.3)
multi_json (1.15.0)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
net-imap (0.5.8)
@ -339,6 +340,9 @@ GEM
rgeo-activerecord (8.0.0)
activerecord (>= 7.0)
rgeo (>= 3.0)
rgeo-geojson (2.2.0)
multi_json (~> 1.15)
rgeo (>= 1.0.0)
rspec-core (3.13.3)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
@ -395,7 +399,7 @@ GEM
sentry-ruby (5.23.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shoulda-matchers (6.4.0)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (7.3.9)
base64
@ -423,6 +427,7 @@ GEM
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stackprof (0.2.27)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.7)
@ -512,6 +517,7 @@ DEPENDENCIES
rexml
rgeo
rgeo-activerecord
rgeo-geojson
rspec-rails
rswag-api
rswag-specs
@ -525,6 +531,7 @@ DEPENDENCIES
sidekiq-limit_fetch
simplecov
sprockets-rails
stackprof
stimulus-rails
strong_migrations
super_diff

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@
class Api::V1::Countries::BordersController < ApplicationController
def index
countries = Rails.cache.fetch('dawarich/countries_codes', expires_in: 1.day) do
Oj.load(File.read(Rails.root.join('lib/assets/countries.json')))
Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson')))
end
render json: countries

View file

@ -2,6 +2,7 @@
class Api::V1::Overland::BatchesController < ApiController
before_action :authenticate_active_api_user!, only: %i[create]
before_action :validate_points_limit, only: %i[create]
def create
Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id)

View file

@ -2,6 +2,7 @@
class Api::V1::Owntracks::PointsController < ApiController
before_action :authenticate_active_api_user!, only: %i[create]
before_action :validate_points_limit, only: %i[create]
def create
Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id)

View file

@ -2,6 +2,7 @@
class Api::V1::PointsController < ApiController
before_action :authenticate_active_api_user!, only: %i[create update destroy]
before_action :validate_points_limit, only: %i[create]
def index
start_at = params[:start_at]&.to_datetime&.to_i

View file

@ -30,7 +30,7 @@ class Api::V1::SettingsController < ApiController
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:speed_colored_routes, :speed_color_scale
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold
)
end
end

View file

@ -2,6 +2,7 @@
class Api::V1::SubscriptionsController < ApiController
skip_before_action :authenticate_api_key, only: %i[callback]
def callback
decoded_token = Subscription::DecodeJwtToken.new(params[:token]).call

View file

@ -41,4 +41,10 @@ class ApiController < ApplicationController
def required_params
[]
end
def validate_points_limit
limit_exceeded = PointsLimitExceeded.new(current_api_user).call
render json: { error: 'Points limit exceeded' }, status: :unauthorized if limit_exceeded
end
end

View file

@ -6,7 +6,7 @@ class ImportsController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_active_user!, only: %i[new create]
before_action :set_import, only: %i[show edit update destroy]
before_action :validate_points_limit, only: %i[new create]
def index
@imports =
current_user
@ -102,4 +102,10 @@ class ImportsController < ApplicationController
import
end
def validate_points_limit
limit_exceeded = PointsLimitExceeded.new(current_user).call
redirect_to imports_path, alert: 'Points limit exceeded', status: :unprocessable_entity if limit_exceeded
end
end

View file

@ -36,7 +36,9 @@ class MapController < ApplicationController
@distance ||= 0
@coordinates.each_cons(2) do
@distance += Geocoder::Calculations.distance_between([_1[0], _1[1]], [_2[0], _2[1]], units: DISTANCE_UNIT)
@distance += Geocoder::Calculations.distance_between(
[_1[0], _1[1]], [_2[0], _2[1]], units: current_user.safe_settings.distance_unit.to_sym
)
end
@distance.round(1)

View file

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

View file

@ -5,7 +5,7 @@ class StatsController < ApplicationController
before_action :authenticate_active_user!, only: %i[update update_all]
def index
@stats = current_user.stats.group_by(&:year).sort.reverse
@stats = current_user.stats.group_by(&:year).transform_values { |stats| stats.sort_by(&:updated_at).reverse }.sort.reverse
@points_total = current_user.tracked_points.count
@points_reverse_geocoded = current_user.total_reverse_geocoded_points
@points_reverse_geocoded_without_data = current_user.total_reverse_geocoded_points_without_data

View file

@ -15,6 +15,10 @@ class TripsController < ApplicationController
@trip.photo_previews
end
@photo_sources = @trip.photo_sources
if @trip.path.blank? || @trip.distance.blank? || @trip.visited_countries.blank?
Trips::CalculateAllJob.perform_later(@trip.id)
end
end
def new
@ -28,7 +32,7 @@ class TripsController < ApplicationController
@trip = current_user.trips.build(trip_params)
if @trip.save
redirect_to @trip, notice: 'Trip was successfully created.'
redirect_to @trip, notice: 'Trip was successfully created. Data is being calculated in the background.'
else
render :new, status: :unprocessable_entity
end

View file

@ -40,7 +40,32 @@ module ApplicationHelper
data[:cities].flatten!.uniq!
data[:countries].flatten!.uniq!
"#{data[:countries].count} countries, #{data[:cities].count} cities"
grouped_by_country = {}
stats.select { _1.year == year }.each do |stat|
stat.toponyms.flatten.each do |toponym|
country = toponym['country']
next unless country.present?
grouped_by_country[country] ||= []
if toponym['cities'].present?
toponym['cities'].each do |city_data|
city = city_data['city']
grouped_by_country[country] << city if city.present?
end
end
end
end
grouped_by_country.transform_values!(&:uniq)
{
countries_count: data[:countries].count,
cities_count: data[:cities].count,
grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
year: year,
modal_id: "countries_cities_modal_#{year}"
}
end
def countries_and_cities_stat_for_month(stat)
@ -51,7 +76,7 @@ module ApplicationHelper
end
def year_distance_stat(year, user)
# In km or miles, depending on the application settings (DISTANCE_UNIT)
# In km or miles, depending on the user.safe_settings.distance_unit
Stat.year_distance(year, user).sum { _1[1] }
end
@ -76,7 +101,7 @@ module ApplicationHelper
def sidebar_distance(distance)
return unless distance
"#{distance} #{DISTANCE_UNIT}"
"#{distance} #{current_user.safe_settings.distance_unit}"
end
def sidebar_points(points)

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
module CountryFlagHelper
def country_flag(country_name)
country_code = country_to_code(country_name)
return "" unless country_code
# Convert country code to regional indicator symbols (flag emoji)
country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
end
private
def country_to_code(country_name)
mapping = Country.names_to_iso_a2
return mapping[country_name] if mapping[country_name]
mapping.each do |name, code|
return code if country_name.downcase == name.downcase
return code if country_name.downcase.include?(name.downcase) || name.downcase.include?(country_name.downcase)
end
nil
end
end

View file

@ -45,8 +45,9 @@ export default class extends BaseController {
this.timezone = this.element.dataset.timezone;
this.userSettings = JSON.parse(this.element.dataset.user_settings);
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
this.fogLinethreshold = parseInt(this.userSettings.fog_of_war_threshold) || 90;
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
this.distanceUnit = this.element.dataset.distance_unit || "km";
this.distanceUnit = this.userSettings.maps.distance_unit || "km";
this.pointsRenderingMode = this.userSettings.points_rendering_mode || "raw";
this.liveMapEnabled = this.userSettings.live_map_enabled || false;
this.countryCodesMap = countryCodesMap();
@ -175,13 +176,13 @@ export default class extends BaseController {
// Update event handlers
this.map.on('moveend', () => {
if (document.getElementById('fog')) {
this.updateFog(this.markers, this.clearFogRadius);
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
}
});
this.map.on('zoomend', () => {
if (document.getElementById('fog')) {
this.updateFog(this.markers, this.clearFogRadius);
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
}
});
@ -198,7 +199,7 @@ export default class extends BaseController {
if (e.name === 'Fog of War') {
fogEnabled = true;
document.getElementById('fog').style.display = 'block';
this.updateFog(this.markers, this.clearFogRadius);
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
}
});
@ -212,7 +213,7 @@ export default class extends BaseController {
// Update fog circles on zoom and move
this.map.on('zoomend moveend', () => {
if (fogEnabled) {
this.updateFog(this.markers, this.clearFogRadius);
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
}
});
@ -350,7 +351,7 @@ export default class extends BaseController {
// Update fog of war if enabled
if (this.map.hasLayer(this.fogOverlay)) {
this.updateFog(this.markers, this.clearFogRadius);
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
}
// Update the last marker
@ -390,7 +391,7 @@ export default class extends BaseController {
const visitedCountries = this.getVisitedCountries(countryCodesMap)
const filteredFeatures = worldData.features.filter(feature =>
visitedCountries.includes(feature.properties.ISO_A2)
visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"])
)
this.scratchLayer.addData({
@ -587,7 +588,7 @@ export default class extends BaseController {
// Update fog if enabled
if (this.map.hasLayer(this.fogOverlay)) {
this.updateFog(this.markers, this.clearFogRadius);
this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold);
}
})
.catch(error => {
@ -623,12 +624,12 @@ export default class extends BaseController {
}
}
updateFog(markers, clearFogRadius) {
updateFog(markers, clearFogRadius, fogLinethreshold) {
const fog = document.getElementById('fog');
if (!fog) {
initializeFogCanvas(this.map);
}
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius));
requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLinethreshold));
}
initializeDrawControl() {
@ -724,7 +725,7 @@ export default class extends BaseController {
// Form HTML
div.innerHTML = `
<form id="settings-form" class="w-48 h-144 overflow-y-auto">
<form id="settings-form" style="overflow-y: auto; height: 36rem; width: 12rem;">
<label for="route-opacity">Route Opacity</label>
<div class="join">
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="route-opacity" name="route_opacity" min="0" max="1" step="0.1" value="${this.routeOpacity}">
@ -738,6 +739,12 @@ export default class extends BaseController {
<label for="fog_of_war_meters_info" class="btn-xs join-item">?</label>
</div>
<label for="fog_of_war_threshold">Seconds between Fog of War lines</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_threshold" name="fog_of_war_threshold" step="1" value="${this.userSettings.fog_of_war_threshold}">
<label for="fog_of_war_threshold_info" class="btn-xs join-item">?</label>
</div>
<label for="meters_between_routes">Meters between routes</label>
<div class="join">
@ -863,6 +870,7 @@ export default class extends BaseController {
settings: {
route_opacity: event.target.route_opacity.value,
fog_of_war_meters: event.target.fog_of_war_meters.value,
fog_of_war_threshold: event.target.fog_of_war_threshold.value,
meters_between_routes: event.target.meters_between_routes.value,
minutes_between_routes: event.target.minutes_between_routes.value,
time_threshold_minutes: event.target.time_threshold_minutes.value,

View file

@ -3,6 +3,7 @@
import BaseController from "./base_controller"
import L from "leaflet"
import { createAllMapLayers } from "../maps/layers"
export default class extends BaseController {
static values = {
@ -31,11 +32,13 @@ export default class extends BaseController {
attributionControl: true
})
// Add the tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a>"
}).addTo(this.map)
// Add base map layer
const selectedLayerName = this.hasUserSettingsValue ?
this.userSettingsValue.preferred_map_layer || "OpenStreetMap" :
"OpenStreetMap";
const maps = this.baseMaps();
const defaultLayer = maps[selectedLayerName] || Object.values(maps)[0];
defaultLayer.addTo(this.map);
// If we have coordinates, show the route
if (this.hasPathValue && this.pathValue) {
@ -45,8 +48,39 @@ export default class extends BaseController {
}
}
baseMaps() {
const selectedLayerName = this.hasUserSettingsValue ?
this.userSettingsValue.preferred_map_layer || "OpenStreetMap" :
"OpenStreetMap";
let maps = createAllMapLayers(this.map, selectedLayerName);
// Add custom map if it exists in settings
if (this.hasUserSettingsValue && this.userSettingsValue.maps && this.userSettingsValue.maps.url) {
const customLayer = L.tileLayer(this.userSettingsValue.maps.url, {
maxZoom: 19,
attribution: "&copy; OpenStreetMap contributors"
});
// If this is the preferred layer, add it to the map immediately
if (selectedLayerName === this.userSettingsValue.maps.name) {
customLayer.addTo(this.map);
// Remove any other base layers that might be active
Object.values(maps).forEach(layer => {
if (this.map.hasLayer(layer)) {
this.map.removeLayer(layer);
}
});
}
maps[this.userSettingsValue.maps.name] = customLayer;
}
return maps;
}
showRoute() {
const points = this.parseLineString(this.pathValue)
const points = this.getCoordinates(this.pathValue)
// Only create polyline if we have points
if (points.length > 0) {
@ -69,37 +103,34 @@ export default class extends BaseController {
}
}
parseLineString(linestring) {
getCoordinates(pathData) {
try {
// Remove 'LINESTRING (' from start and ')' from end
const coordsString = linestring
.replace(/LINESTRING\s*\(/, '') // Remove LINESTRING and opening parenthesis
.replace(/\)$/, '') // Remove closing parenthesis
.trim() // Remove any leading/trailing whitespace
// Parse the path data if it's a string
let coordinates = pathData;
if (typeof pathData === 'string') {
try {
coordinates = JSON.parse(pathData);
} catch (e) {
console.error("Error parsing path data as JSON:", e);
return [];
}
}
// Split into coordinate pairs and parse
const points = coordsString.split(',').map(pair => {
// Clean up any extra whitespace and remove any special characters
const cleanPair = pair.trim().replace(/[()"\s]+/g, ' ')
const [lng, lat] = cleanPair.split(' ').filter(Boolean).map(Number)
// Handle array format - convert from [lng, lat] to [lat, lng] for Leaflet
return coordinates.map(coord => {
const [lng, lat] = coord;
// Validate the coordinates
if (isNaN(lat) || isNaN(lng) || !lat || !lng) {
console.error("Invalid coordinates:", cleanPair)
return null
console.error("Invalid coordinates:", coord);
return null;
}
return [lat, lng] // Leaflet uses [lat, lng] order
}).filter(point => point !== null) // Remove any invalid points
// Validate we have points before returning
if (points.length === 0) {
return []
}
return points
return [lat, lng]; // Leaflet uses [lat, lng] order
}).filter(point => point !== null);
} catch (error) {
return []
console.error("Error processing coordinates:", error);
return [];
}
}

View file

@ -26,7 +26,7 @@ export default class extends BaseController {
this.apiKey = this.containerTarget.dataset.api_key
this.userSettings = JSON.parse(this.containerTarget.dataset.user_settings || '{}')
this.timezone = this.containerTarget.dataset.timezone
this.distanceUnit = this.containerTarget.dataset.distance_unit
this.distanceUnit = this.userSettings.maps.distance_unit || "km"
// Initialize map and layers
this.initializeMap()
@ -133,22 +133,31 @@ export default class extends BaseController {
// After map initialization, add the path if it exists
if (this.containerTarget.dataset.path) {
const pathData = this.containerTarget.dataset.path.replace(/^"|"$/g, ''); // Remove surrounding quotes
const coordinates = this.parseLineString(pathData);
try {
let coordinates;
const pathData = this.containerTarget.dataset.path.replace(/^"|"$/g, ''); // Remove surrounding quotes
const polyline = L.polyline(coordinates, {
color: 'blue',
opacity: 0.8,
weight: 3,
zIndexOffset: 400
});
// Try to parse as JSON first (new format)
coordinates = JSON.parse(pathData);
// Convert from [lng, lat] to [lat, lng] for Leaflet
coordinates = coordinates.map(coord => [coord[1], coord[0]]);
polyline.addTo(this.polylinesLayer);
this.polylinesLayer.addTo(this.map);
const polyline = L.polyline(coordinates, {
color: 'blue',
opacity: 0.8,
weight: 3,
zIndexOffset: 400
});
// Fit the map to the polyline bounds
if (coordinates.length > 0) {
this.map.fitBounds(polyline.getBounds(), { padding: [50, 50] });
polyline.addTo(this.polylinesLayer);
this.polylinesLayer.addTo(this.map);
// Fit the map to the polyline bounds
if (coordinates.length > 0) {
this.map.fitBounds(polyline.getBounds(), { padding: [50, 50] });
}
} catch (error) {
console.error("Error processing path data:", error);
}
}
}
@ -246,17 +255,4 @@ export default class extends BaseController {
this.fitMapToBounds()
}
}
// Add this method to parse the LineString format
parseLineString(lineString) {
// Remove LINESTRING and parentheses, then split into coordinate pairs
const coordsString = lineString.replace('LINESTRING (', '').replace(')', '');
const coords = coordsString.split(', ');
// Convert each coordinate pair to [lat, lng] format
return coords.map(coord => {
const [lng, lat] = coord.split(' ').map(Number);
return [lat, lng]; // Swap to lat, lng for Leaflet
});
}
}

View file

@ -23,7 +23,7 @@ export function initializeFogCanvas(map) {
return fog;
}
export function drawFogCanvas(map, markers, clearFogRadius) {
export function drawFogCanvas(map, markers, clearFogRadius, fogLinethreshold) {
const fog = document.getElementById('fog');
// Return early if fog element doesn't exist or isn't a canvas
if (!fog || !(fog instanceof HTMLCanvasElement)) return;
@ -33,38 +33,60 @@ export function drawFogCanvas(map, markers, clearFogRadius) {
const size = map.getSize();
// Clear the canvas
// 1) Paint base fog
ctx.clearRect(0, 0, size.x, size.y);
// Keep the light fog for unexplored areas
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillRect(0, 0, size.x, size.y);
// Set up for "cutting" holes
// 2) Cut out holes
ctx.globalCompositeOperation = 'destination-out';
// Draw clear circles for each point
markers.forEach(point => {
const latLng = L.latLng(point[0], point[1]);
const pixelPoint = map.latLngToContainerPoint(latLng);
const radiusInPixels = metersToPixels(map, clearFogRadius);
// 3) Build & sort points
const pts = markers
.map(pt => {
const pixel = map.latLngToContainerPoint(L.latLng(pt[0], pt[1]));
return { pixel, time: parseInt(pt[4], 10) };
})
.sort((a, b) => a.time - b.time);
// Make explored areas completely transparent
const gradient = ctx.createRadialGradient(
pixelPoint.x, pixelPoint.y, 0,
pixelPoint.x, pixelPoint.y, radiusInPixels
);
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); // 100% transparent
gradient.addColorStop(0.85, 'rgba(255, 255, 255, 1)'); // Still 100% transparent
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); // Fade to fog at edge
const radiusPx = Math.max(metersToPixels(map, clearFogRadius), 2);
console.log(radiusPx);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(pixelPoint.x, pixelPoint.y, radiusInPixels, 0, Math.PI * 2);
ctx.fill();
// 4) Mark which pts are part of a line
const connected = new Array(pts.length).fill(false);
for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1].time - pts[i].time <= fogLinethreshold) {
connected[i] = true;
connected[i + 1] = true;
}
}
// 5) Draw circles only for “alone” points
pts.forEach((pt, i) => {
if (!connected[i]) {
ctx.fillStyle = 'rgba(255,255,255,1)';
ctx.beginPath();
ctx.arc(pt.pixel.x, pt.pixel.y, radiusPx, 0, Math.PI * 2);
ctx.fill();
}
});
// Reset composite operation
// 6) Draw rounded lines
ctx.lineWidth = radiusPx * 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(255,255,255,1)';
for (let i = 0; i < pts.length - 1; i++) {
if (pts[i + 1].time - pts[i].time <= fogLinethreshold) {
ctx.beginPath();
ctx.moveTo(pts[i].pixel.x, pts[i].pixel.y);
ctx.lineTo(pts[i + 1].pixel.x, pts[i + 1].pixel.y);
ctx.stroke();
}
}
// 7) Reset composite operation
ctx.globalCompositeOperation = 'source-over';
}

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class DataMigrations::SetPointsCountryIdsJob < ApplicationJob
queue_as :default
def perform(point_id)
point = Point.find(point_id)
point.country_id = Country.containing_point(point.lon, point.lat).id
point.save!
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class DataMigrations::StartSettingsPointsCountryIdsJob < ApplicationJob
queue_as :default
def perform
Point.where(country_id: nil).find_each do |point|
DataMigrations::SetPointsCountryIdsJob.perform_later(point.id)
end
end
end

View file

@ -9,6 +9,7 @@ class Overland::BatchCreatingJob < ApplicationJob
data = Overland::Params.new(params).call
data.each do |location|
next if location[:lonlat].nil?
next if point_exists?(location, user_id)
Point.create!(location.merge(user_id:))

View file

@ -8,6 +8,7 @@ class Owntracks::PointCreatingJob < ApplicationJob
def perform(point_params, user_id)
parsed_params = OwnTracks::Params.new(point_params).call
return if parsed_params[:timestamp].nil? || parsed_params[:lonlat].nil?
return if point_exists?(parsed_params, user_id)
Point.create!(parsed_params.merge(user_id:))

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Trips::CalculateAllJob < ApplicationJob
queue_as :default
def perform(trip_id)
Trips::CalculatePathJob.perform_later(trip_id)
Trips::CalculateDistanceJob.perform_later(trip_id)
Trips::CalculateCountriesJob.perform_later(trip_id)
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Trips::CalculateCountriesJob < ApplicationJob
queue_as :default
def perform(trip_id)
trip = Trip.find(trip_id)
trip.calculate_countries
trip.save!
broadcast_update(trip)
end
private
def broadcast_update(trip)
Turbo::StreamsChannel.broadcast_update_to(
"trip_#{trip.id}",
target: "trip_countries",
partial: "trips/countries",
locals: { trip: trip }
)
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Trips::CalculateDistanceJob < ApplicationJob
queue_as :default
def perform(trip_id)
trip = Trip.find(trip_id)
trip.calculate_distance
trip.save!
broadcast_update(trip)
end
private
def broadcast_update(trip)
Turbo::StreamsChannel.broadcast_update_to(
"trip_#{trip.id}",
target: "trip_distance",
partial: "trips/distance",
locals: { trip: trip }
)
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Trips::CalculatePathJob < ApplicationJob
queue_as :default
def perform(trip_id)
trip = Trip.find(trip_id)
trip.calculate_path
trip.save!
broadcast_update(trip)
end
private
def broadcast_update(trip)
Turbo::StreamsChannel.broadcast_update_to(
"trip_#{trip.id}",
target: "trip_path",
partial: "trips/path",
locals: { trip: trip }
)
end
end

View file

@ -1,13 +0,0 @@
# frozen_string_literal: true
class Trips::CreatePathJob < ApplicationJob
queue_as :default
def perform(trip_id)
trip = Trip.find(trip_id)
trip.calculate_path_and_distance
trip.save!
end
end

View file

@ -3,14 +3,6 @@
module Distanceable
extend ActiveSupport::Concern
DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
module ClassMethods
def total_distance(points = nil, unit = :km)
# Handle method being called directly on relation vs with array
@ -24,8 +16,8 @@ module Distanceable
private
def calculate_distance_for_relation(unit)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
unless ::DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
distance_in_meters = connection.select_value(<<-SQL.squish)
@ -48,12 +40,12 @@ module Distanceable
WHERE prev_lonlat IS NOT NULL
SQL
distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
end
def calculate_distance_for_array(points, unit = :km)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
unless ::DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
return 0 if points.length < 2
@ -66,13 +58,13 @@ module Distanceable
)
end
total_meters.to_f / DISTANCE_UNITS[unit.to_sym]
total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
end
end
def distance_to(other_point, unit = :km)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
unless ::DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
# Extract coordinates based on what type other_point is
@ -88,7 +80,7 @@ module Distanceable
SQL
# Convert to requested unit
distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
end
private

View file

@ -3,14 +3,6 @@
module Nearable
extend ActiveSupport::Concern
DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
class_methods do
# It accepts an array of coordinates [latitude, longitude]
# and an optional radius and distance unit
@ -19,12 +11,12 @@ module Nearable
def near(*args)
latitude, longitude, radius, unit = extract_coordinates_and_options(*args)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
unless ::DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
# Convert radius to meters for ST_DWithin
radius_in_meters = radius * DISTANCE_UNITS[unit.to_sym]
radius_in_meters = radius * ::DISTANCE_UNITS[unit.to_sym]
# Create a point from the given coordinates
point = "SRID=4326;POINT(#{longitude} #{latitude})"
@ -41,12 +33,12 @@ module Nearable
def with_distance(*args)
latitude, longitude, unit = extract_coordinates_and_options(*args)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
unless ::DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
point = "SRID=4326;POINT(#{longitude} #{latitude})"
conversion_factor = 1.0 / DISTANCE_UNITS[unit.to_sym]
conversion_factor = 1.0 / ::DISTANCE_UNITS[unit.to_sym]
select(<<-SQL.squish)
#{table_name}.*,

15
app/models/country.rb Normal file
View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Country < ApplicationRecord
validates :name, :iso_a2, :iso_a3, :geom, presence: true
def self.containing_point(lon, lat)
where("ST_Contains(geom, ST_SetSRID(ST_MakePoint(?, ?), 4326))", lon, lat)
.select(:id, :name, :iso_a2, :iso_a3)
.first
end
def self.names_to_iso_a2
pluck(:name, :iso_a2).to_h
end
end

View file

@ -22,29 +22,19 @@ class Place < ApplicationRecord
lonlat.y
end
def async_reverse_geocode
return unless DawarichSettings.reverse_geocoding_enabled?
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
def reverse_geocoded?
geodata.present?
end
def osm_id
geodata['properties']['osm_id']
geodata.dig('properties', 'osm_id')
end
def osm_key
geodata['properties']['osm_key']
geodata.dig('properties', 'osm_key')
end
def osm_value
geodata['properties']['osm_value']
geodata.dig('properties', 'osm_value')
end
def osm_type
geodata['properties']['osm_type']
geodata.dig('properties', 'osm_type')
end
end

View file

@ -28,7 +28,8 @@ class Point < ApplicationRecord
scope :visited, -> { where.not(visit_id: nil) }
scope :not_visited, -> { where(visit_id: nil) }
after_create :async_reverse_geocode
after_create :async_reverse_geocode, if: -> { DawarichSettings.store_geodata? }
after_create :set_country
after_create_commit :broadcast_coordinates
def self.without_raw_data
@ -57,6 +58,10 @@ class Point < ApplicationRecord
lonlat.y
end
def found_in_country
Country.containing_point(lon, lat)
end
private
# rubocop:disable Metrics/MethodLength Metrics/AbcSize
@ -76,4 +81,9 @@ class Point < ApplicationRecord
)
end
# rubocop:enable Metrics/MethodLength
def set_country
self.country_id = found_in_country&.id
save! if changed?
end
end

View file

@ -37,7 +37,7 @@ class Stat < ApplicationRecord
def calculate_daily_distances(monthly_points)
timespan.to_a.map.with_index(1) do |day, index|
daily_points = filter_points_for_day(monthly_points, day)
distance = Point.total_distance(daily_points, DISTANCE_UNIT)
distance = Point.total_distance(daily_points, user.safe_settings.distance_unit)
[index, distance.round(2)]
end
end

View file

@ -7,11 +7,11 @@ class Trip < ApplicationRecord
validates :name, :started_at, :ended_at, presence: true
before_save :calculate_path_and_distance
after_create :enqueue_calculation_jobs
after_update :enqueue_calculation_jobs, if: -> { saved_change_to_started_at? || saved_change_to_ended_at? }
def calculate_path_and_distance
calculate_path
calculate_distance
def enqueue_calculation_jobs
Trips::CalculateAllJob.perform_later(id)
end
def points
@ -19,7 +19,9 @@ class Trip < ApplicationRecord
end
def countries
points.pluck(:country).uniq.compact
return points.pluck(:country).uniq.compact if DawarichSettings.store_geodata?
visited_countries
end
def photo_previews
@ -30,6 +32,25 @@ class Trip < ApplicationRecord
@photo_sources ||= photos.map { _1[:source] }.uniq
end
def calculate_path
trip_path = Tracks::BuildPath.new(points.pluck(:lonlat)).call
self.path = trip_path
end
def calculate_distance
distance = Point.total_distance(points, user.safe_settings.distance_unit)
self.distance = distance.round
end
def calculate_countries
countries =
Country.where(id: points.pluck(:country_id).compact.uniq).pluck(:name)
self.visited_countries = countries
end
private
def photos
@ -44,16 +65,4 @@ class Trip < ApplicationRecord
# to show all photos in the same height
vertical_photos.count > horizontal_photos.count ? vertical_photos : horizontal_photos
end
def calculate_path
trip_path = Tracks::BuildPath.new(points.pluck(:lonlat)).call
self.path = trip_path
end
def calculate_distance
distance = Point.total_distance(points, DISTANCE_UNIT)
self.distance = distance.round
end
end

View file

@ -49,7 +49,7 @@ class User < ApplicationRecord
end
def total_distance
# In km or miles, depending on the application settings (DISTANCE_UNIT)
# In km or miles, depending on user.safe_settings.distance_unit
stats.sum(:distance)
end

View file

@ -12,10 +12,6 @@ class Visit < ApplicationRecord
enum :status, { suggested: 0, confirmed: 1, declined: 2 }
def reverse_geocoded?
place.geodata.present?
end
def coordinates
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
end
@ -29,7 +25,9 @@ class Visit < ApplicationRecord
return area&.radius if area.present?
radius = points.map do |point|
Geocoder::Calculations.distance_between(center, [point.lat, point.lon])
Geocoder::Calculations.distance_between(
center, [point.lat, point.lon], units: user.safe_settings.distance_unit.to_sym
)
end.max
radius && radius >= 15 ? radius : 15

View file

@ -7,14 +7,16 @@ class Api::PlaceSerializer
def call
{
id: place.id,
name: place.name,
longitude: place.lon,
latitude: place.lat,
city: place.city,
country: place.country,
source: place.source,
geodata: place.geodata,
id: place.id,
name: place.name,
longitude: place.lon,
latitude: place.lat,
city: place.city,
country: place.country,
source: place.source,
geodata: place.geodata,
created_at: place.created_at,
updated_at: place.updated_at,
reverse_geocoded_at: place.reverse_geocoded_at
}
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::PointSerializer < PointSerializer
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data].freeze
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data country_id].freeze
def call
point.attributes.except(*EXCLUDED_ATTRIBUTES)

View file

@ -3,7 +3,7 @@
class PointSerializer
EXCLUDED_ATTRIBUTES = %w[
created_at updated_at visit_id id import_id user_id raw_data lonlat
reverse_geocoded_at
reverse_geocoded_at country_id
].freeze
def initialize(point)

View file

@ -31,14 +31,14 @@ class Areas::Visits::Create
def area_points(area)
area_radius =
if ::DISTANCE_UNIT == :km
area.radius / 1000.0
if user.safe_settings.distance_unit == :km
area.radius / ::DISTANCE_UNITS[:km]
else
area.radius / 1609.344
area.radius / ::DISTANCE_UNITS[user.safe_settings.distance_unit.to_sym]
end
points = Point.where(user_id: user.id)
.near([area.latitude, area.longitude], area_radius, DISTANCE_UNIT)
.near([area.latitude, area.longitude], area_radius, user.safe_settings.distance_unit)
.order(timestamp: :asc)
# check if all points within the area are assigned to a visit

View file

@ -18,6 +18,7 @@ class Geojson::Importer
data = Geojson::Params.new(json).call
data.each.with_index(1) do |point, index|
next if point[:lonlat].nil?
next if point_exists?(point, user_id)
Point.create!(point.merge(user_id:, import_id: import.id))

View file

@ -95,7 +95,9 @@ class Geojson::Params
end
def speed(feature)
feature.dig(:properties, :speed).to_f.round(1)
value = feature.dig(:properties, :speed) || feature.dig(:properties, :velocity)
value.to_f.round(1)
end
def accuracy(feature)

View file

@ -21,8 +21,6 @@ class Jobs::Create
raise InvalidJobName, 'Invalid job name'
end
points.find_each(batch_size: 1_000) do |point|
point.async_reverse_geocode
end
points.find_each(&:async_reverse_geocode)
end
end

View file

@ -11,9 +11,12 @@ class Points::Create
def call
data = Points::Params.new(params, user.id).call
# Deduplicate points based on unique constraint
deduplicated_data = data.uniq { |point| [point[:lonlat], point[:timestamp], point[:user_id]] }
created_points = []
data.each_slice(1000) do |location_batch|
deduplicated_data.each_slice(1000) do |location_batch|
# rubocop:disable Rails/SkipsModelValidations
result = Point.upsert_all(
location_batch,

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class PointsLimitExceeded
def initialize(user)
@user = user
end
def call
return false if DawarichSettings.self_hosted?
return true if @user.points.count >= points_limit
false
end
private
def points_limit
DawarichSettings::BASIC_PAID_PLAN_LIMIT
end
end

View file

@ -17,10 +17,22 @@ class ReverseGeocoding::Places::FetchData
return
end
first_place = reverse_geocoded_places.shift
places = reverse_geocoded_places
first_place = places.shift
update_place(first_place)
reverse_geocoded_places.each { |reverse_geocoded_place| fetch_and_create_place(reverse_geocoded_place) }
osm_ids = places.map { |place| place.data['properties']['osm_id'].to_s }
return if osm_ids.empty?
existing_places =
Place.where("geodata->'properties'->>'osm_id' IN (?)", osm_ids)
.index_by { |p| p.geodata.dig('properties', 'osm_id').to_s }
.compact
places.each do |reverse_geocoded_place|
fetch_and_create_place(reverse_geocoded_place, existing_places)
end
end
private
@ -41,13 +53,13 @@ class ReverseGeocoding::Places::FetchData
)
end
def fetch_and_create_place(reverse_geocoded_place)
def fetch_and_create_place(reverse_geocoded_place, existing_places)
data = reverse_geocoded_place.data
new_place = find_place(data)
new_place = find_place(data, existing_places)
new_place.name = place_name(data)
new_place.city = data['properties']['city']
new_place.country = data['properties']['country']
new_place.country = data['properties']['country'] # TODO: Use country id
new_place.geodata = data
new_place.source = :photon
if new_place.lonlat.blank?
@ -57,18 +69,14 @@ class ReverseGeocoding::Places::FetchData
new_place.save!
end
def reverse_geocoded?
place.geodata.present?
end
def find_place(place_data, existing_places)
osm_id = place_data['properties']['osm_id'].to_s
def find_place(place_data)
found_place = Place.where(
"geodata->'properties'->>'osm_id' = ?", place_data['properties']['osm_id'].to_s
).first
existing_place = existing_places[osm_id]
return existing_place if existing_place.present?
return found_place if found_place.present?
Place.find_or_initialize_by(
# If not found in existing places, initialize a new one
Place.new(
lonlat: "POINT(#{place_data['geometry']['coordinates'][0].to_f.round(5)} #{place_data['geometry']['coordinates'][1].to_f.round(5)})",
latitude: place_data['geometry']['coordinates'][1].to_f.round(5),
longitude: place_data['geometry']['coordinates'][0].to_f.round(5)
@ -92,7 +100,7 @@ class ReverseGeocoding::Places::FetchData
limit: 10,
distance_sort: true,
radius: 1,
units: ::DISTANCE_UNIT
units: :km
)
data.reject do |place|

View file

@ -6,6 +6,8 @@ class ReverseGeocoding::Points::FetchData
def initialize(point_id)
@point = Point.find(point_id)
rescue ActiveRecord::RecordNotFound => e
ExceptionReporter.call(e)
Rails.logger.error("Point with id #{point_id} not found: #{e.message}")
end

View file

@ -8,7 +8,11 @@ class Stats::CalculateMonth
end
def call
return if points.empty?
if points.empty?
destroy_month_stats(year, month)
return
end
update_month_stats(year, month)
rescue StandardError => e
@ -66,4 +70,8 @@ class Stats::CalculateMonth
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
).call
end
def destroy_month_stats(year, month)
Stat.where(year:, month:, user:).destroy_all
end
end

View file

@ -24,7 +24,8 @@ class Users::SafeSettings
immich_api_key: immich_api_key,
photoprism_url: photoprism_url,
photoprism_api_key: photoprism_api_key,
maps: maps
maps: maps,
distance_unit: distance_unit
}
end
# rubocop:enable Metrics/MethodLength
@ -90,4 +91,8 @@ class Users::SafeSettings
def maps
settings['maps'] || {}
end
def distance_unit
settings.dig('maps', 'distance_unit') || 'km'
end
end

View file

@ -11,6 +11,10 @@ module Visits
def create_visits(visits)
visits.map do |visit_data|
# Check for existing confirmed visits at this location
existing_confirmed = find_existing_confirmed_visit(visit_data)
next existing_confirmed if existing_confirmed
# Variables to store data outside the transaction
visit_instance = nil
place_data = nil
@ -46,11 +50,46 @@ module Visits
end
visit_instance
end
end.compact
end
private
# Find if there's already a confirmed visit at this location within a similar time
def find_existing_confirmed_visit(visit_data)
# Define time window to look for existing visits (slightly wider than the visit)
start_time = Time.zone.at(visit_data[:start_time]) - 1.hour
end_time = Time.zone.at(visit_data[:end_time]) + 1.hour
# Look for confirmed visits with a similar location
user.visits
.confirmed
.where('(started_at BETWEEN ? AND ?) OR (ended_at BETWEEN ? AND ?)',
start_time, end_time, start_time, end_time)
.find_each do |visit|
# Skip if the visit doesn't have place or area coordinates
next unless visit.place || visit.area
# Get coordinates to compare
visit_lat = visit.place&.lat || visit.area&.latitude
visit_lon = visit.place&.lon || visit.area&.longitude
next unless visit_lat && visit_lon
# Calculate distance between centers
distance = Geocoder::Calculations.distance_between(
[visit_data[:center_lat], visit_data[:center_lon]],
[visit_lat, visit_lon],
units: :km
)
# If this confirmed visit is within 100 meters of the new suggestion
return visit if distance <= 0.1
end
nil
end
# Create place_visits records directly to avoid deadlocks
def associate_suggested_places(visit, suggested_places)
existing_place_ids = visit.place_visits.pluck(:place_id)

View file

@ -7,10 +7,11 @@ module Visits
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 2
attr_reader :points
attr_reader :points, :place_name_suggester
def initialize(points)
@points = points
@place_name_suggester = Visits::Names::Suggester
end
def detect_potential_visits
@ -89,7 +90,7 @@ module Visits
center_lat: center[0],
center_lon: center[1],
radius: calculate_visit_radius(points, center),
suggested_name: suggest_place_name(points)
suggested_name: suggest_place_name(points) || fetch_place_name(center)
)
end
@ -111,48 +112,11 @@ module Visits
end
def suggest_place_name(points)
# Get points with geodata
geocoded_points = points.select { |p| p.geodata.present? && !p.geodata.empty? }
return nil if geocoded_points.empty?
place_name_suggester.new(points).call
end
# Extract all features from points' geodata
features = geocoded_points.flat_map do |point|
next [] unless point.geodata['features'].is_a?(Array)
point.geodata['features']
end.compact
return nil if features.empty?
# Group features by type and count occurrences
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
.transform_values(&:size)
# Find the most common feature type
most_common_type = feature_counts.max_by { |_, count| count }&.first
return nil unless most_common_type
# Get all features of the most common type
common_features = features.select { |f| f.dig('properties', 'type') == most_common_type }
# Group these features by name and get the most common one
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
.transform_values(&:size)
most_common_name = name_counts.max_by { |_, count| count }&.first
return if most_common_name.blank?
# If we have a name, try to get additional context
feature = common_features.find { |f| f.dig('properties', 'name') == most_common_name }
properties = feature['properties']
# Build a more descriptive name if possible
[
most_common_name,
properties['street'],
properties['city'],
properties['state']
].compact.uniq.join(', ')
def fetch_place_name(center)
Visits::Names::Fetcher.new(center).call
end
end
end

View file

@ -0,0 +1,68 @@
# frozen_string_literal: true
module Visits
module Names
# Builds descriptive names for places from geodata features
class Builder
def self.build_from_properties(properties)
return nil if properties.blank?
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['city'],
properties['state']
].compact.reject(&:empty?).uniq
name_components.any? ? name_components.join(', ') : nil
end
def initialize(features, feature_type, name)
@features = features
@feature_type = feature_type
@name = name
end
def call
return nil if features.blank? || feature_type.blank? || name.blank?
return nil unless feature
[
name,
properties['street'],
properties['city'],
properties['state']
].compact.uniq.join(', ')
end
private
attr_reader :features, :feature_type, :name
def feature
@feature ||= find_feature
end
def find_feature
features.find do |f|
f.dig('properties', 'type') == feature_type &&
f.dig('properties', 'name') == name
end || find_feature_by_osm_value
end
def find_feature_by_osm_value
features.find do |f|
f.dig('properties', 'osm_value') == feature_type &&
f.dig('properties', 'name') == name
end
end
def properties
return {} unless feature && feature['properties'].is_a?(Hash)
feature['properties']
end
end
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
module Visits
module Names
# Fetches names for places from reverse geocoding API
class Fetcher
def initialize(center)
@center = center
end
def call
return nil if geocoder_results.blank?
build_place_name
end
private
attr_reader :center
def geocoder_results
@geocoder_results ||= Geocoder.search(
center, limit: 10, distance_sort: true, radius: 1, units: :km
)
end
def build_place_name
return nil if geocoder_results.first&.data.blank?
return nil if properties.blank?
# First try the direct properties approach
name = Visits::Names::Builder.build_from_properties(properties)
return name if name.present?
# Fall back to the instance-based approach
return nil unless properties['name'] && properties['osm_value']
Visits::Names::Builder.new(
features,
properties['osm_value'],
properties['name']
).call
end
def features
geocoder_results.map do |result|
{
'properties' => result.data['properties']
}
end.compact
end
def properties
@properties ||= geocoder_results.first.data['properties']
end
end
end
end

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
module Visits
module Names
# Suggests names for places based on geodata from tracked points
class Suggester
def initialize(points)
@points = points
end
def call
geocoded_points = extract_geocoded_points(points)
return nil if geocoded_points.empty?
features = extract_features(geocoded_points)
return nil if features.empty?
most_common_type = find_most_common_feature_type(features)
return nil unless most_common_type
most_common_name = find_most_common_name(features, most_common_type)
return nil if most_common_name.blank?
Visits::Names::Builder.new(
features, most_common_type, most_common_name
).call
end
private
attr_reader :points
def extract_geocoded_points(points)
points.select { |p| p.geodata.present? && !p.geodata.empty? }
end
def extract_features(geocoded_points)
geocoded_points.flat_map do |point|
next [] unless point.geodata['features'].is_a?(Array)
point.geodata['features']
end.compact
end
def find_most_common_feature_type(features)
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
.transform_values(&:size)
feature_counts.max_by { |_, count| count }&.first
end
def find_most_common_name(features, feature_type)
common_features = features.select { |f| f.dig('properties', 'type') == feature_type }
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
.transform_values(&:size)
name_counts.max_by { |_, count| count }&.first
end
end
end
end

View file

@ -51,7 +51,7 @@ module Visits
return existing_by_location if existing_by_location
# Then try by name if available
return nil unless name.present?
return nil if name.blank?
Place.where(name: name)
.near([lat, lon], SEARCH_RADIUS, :m)
@ -64,16 +64,13 @@ module Visits
lon = visit_data[:center_lon]
# Get places from points' geodata
places_from_points = extract_places_from_points(visit_data[:points], lat, lon)
# Get places from external API
places_from_api = fetch_places_from_api(lat, lon)
places_from_points = extract_places_from_points(visit_data[:points])
# Combine and deduplicate by name
combined_places = []
# Add API places first (usually better quality)
places_from_api.each do |api_place|
reverse_geocoded_places(lat, lon).each do |api_place|
combined_places << api_place unless place_name_exists?(combined_places, api_place.name)
end
@ -86,7 +83,7 @@ module Visits
end
# Step 3: Extract places from points
def extract_places_from_points(points, center_lat, center_lon)
def extract_places_from_points(points)
return [] if points.blank?
# Filter points with geodata
@ -101,7 +98,7 @@ module Visits
places << place if place
end
places.uniq { |place| place.name }
places.uniq(&:name)
end
# Step 4: Create place from point
@ -141,7 +138,7 @@ module Visits
end
# Step 5: Fetch places from API
def fetch_places_from_api(lat, lon)
def reverse_geocoded_places(lat, lon)
# Get broader search results from Geocoder
geocoder_results = Geocoder.search([lat, lon], units: :km, limit: 20, distance_sort: true)
return [] if geocoder_results.blank?
@ -228,15 +225,22 @@ module Visits
# Helper methods
def build_place_name(properties)
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.reject(&:empty?).uniq
# First try building with our name builder
built_name = Visits::Names::Builder.build_from_properties(properties)
return built_name if built_name.present?
name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
# Try using the instance-based approach as a fallback
features = [{ 'properties' => properties }]
feature_type = properties['type'] || properties['osm_value']
name = properties['name']
if feature_type.present? && name.present?
built_name = Visits::Names::Builder.new(features, feature_type, name).call
return built_name if built_name.present?
end
# Fallback to the default name if all else fails
Place::DEFAULT_NAME
end
def place_name_exists?(places, name)

View file

@ -6,11 +6,21 @@
<h3 class='text-xl font-bold mt-4'>Usage examples</h3>
<h3 class='text-lg font-bold mt-4'>Dawarich iOS app</h3>
<p>Provide your instance URL:</p>
<p class='mb-2'><code><%= root_url %></code></p>
<p>And provide your API key:</p>
<p><code><%= current_user.api_key %></code></p>
<div class='divider'>OR</div>
<h3 class='text-lg font-bold mt-4'>OwnTracks</h3>
<p><code><%= api_v1_owntracks_points_url(api_key: current_user.api_key) %></code></p>
<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

@ -0,0 +1,6 @@
<p class="py-6">
<p class='py-2'>
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.points.count %>" max="<%= DawarichSettings::BASIC_PAID_PLAN_LIMIT %>"></progress>
</p>

View file

@ -1,10 +1,13 @@
<% content_for :title, 'Account' %>
<div class="hero min-h-content bg-base-200">
<div class="hero-content flex-col lg:flex-row-reverse w-full my-10">
<div class="hero-content flex-col lg:flex-row-reverse w-full my-5">
<div class="text-center lg:text-left">
<h1 class="text-5xl font-bold">Edit your account!</h1>
<h1 class="text-5xl font-bold mb-5">Edit your account!</h1>
<%= render 'devise/registrations/api_key' %>
<% if !DawarichSettings.self_hosted? %>
<%= render 'devise/registrations/points_usage' %>
<% end %>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', method: :put, data: { turbo_method: :put, turbo: false }) do |f| %>

View file

@ -8,7 +8,10 @@
</h1>
<p class="py-6 text-3xl">The only location history tracker you'll ever need.</p>
<%#= link_to 'Sign up', new_user_registration_path, class: "rounded-lg py-3 px-5 my-3 bg-blue-600 text-white block font-medium" %>
<% if !DawarichSettings.self_hosted? %>
<%= link_to 'Sign up', new_user_registration_path, class: "rounded-lg py-3 px-5 my-3 bg-blue-600 text-white block font-medium" %>
<div class="divider">or</div>
<% end %>
<%= link_to 'Sign in', new_user_session_path, class: "rounded-lg py-3 px-5 bg-neutral text-neutral-content block font-medium" %>
</div>
</div>

View file

@ -19,7 +19,7 @@
<div id="imports" class="min-w-full">
<% if @imports.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero min-h-80 bg-base-200 my-5">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
@ -41,7 +41,9 @@
<tr>
<th>Name</th>
<th>Imported points</th>
<th>Reverse geocoded points</th>
<% if DawarichSettings.store_geodata? %>
<th>Reverse geocoded points</th>
<% end %>
<th>Created at</th>
</tr>
</thead>
@ -65,9 +67,11 @@
<td data-points-count>
<%= number_with_delimiter import.processed %>
</td>
<td data-reverse-geocoded-points-count>
<%= number_with_delimiter import.reverse_geocoded_points_count %>
</td>
<% if DawarichSettings.store_geodata? %>
<td data-reverse-geocoded-points-count>
<%= number_with_delimiter import.reverse_geocoded_points_count %>
</td>
<% end %>
<td><%= human_datetime(import.created_at) %></td>
</tr>
<% end %>

View file

@ -27,6 +27,20 @@
<label class="modal-backdrop" for="fog_of_war_meters_info">Close</label>
</div>
<input type="checkbox" id="fog_of_war_threshold_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Fog of War Line Threshold</h3>
<p class="py-4">
Value in seconds.
</p>
<p class="py-4">
Points in the fog are connected by lines. This value is the maximum time between two points to be connected by a line. If the time between two points is greater than this value, they will not be connected.
</p>
</div>
<label class="modal-backdrop" for="fog_of_war_threshold_info">Close</label>
</div>
<input type="checkbox" id="meters_between_routes_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">

View file

@ -5,18 +5,36 @@
<div class="flex flex-col space-y-4 mb-4 w-full">
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
<div class="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4 sm:items-end">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
◀️
<% end %>
</span>
</div>
</div>
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="flex flex-col space-y-2">
<%= f.label :start_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @start_at %>
</div>
</div>
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-3/12">
<div class="w-full sm:w-2/12 md:w-1/12 lg:w-2/12">
<div class="flex flex-col space-y-2">
<%= f.label :end_at, class: "text-sm font-semibold" %>
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-bordered hover:cursor-pointer hover:input-primary", value: @end_at %>
</div>
</div>
<div class="w-full sm:w-1/12 md:w-1/12 lg:w-1/12">
<div class="flex flex-col space-y-2">
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn btn-neutral hover:btn-ghost w-full" do %>
▶️
<% end %>
</span>
</div>
</div>
<div class="w-full sm:w-6/12 md:w-2/12 lg:w-1/12">
<div class="flex flex-col space-y-2">
<%= f.submit "Search", class: "btn btn-primary hover:btn-info" %>
@ -47,7 +65,6 @@
class="w-full z-0"
data-controller="maps points"
data-points-target="map"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-self_hosted="<%= @self_hosted %>"
data-user_settings='<%= current_user.settings.to_json.html_safe %>'

View file

@ -1,8 +1,8 @@
<div role="tablist" class="tabs tabs-lifted tabs-lg">
<%= link_to 'Integrations', settings_path, role: 'tab', class: "tab #{active_tab?(settings_path)}" %>
<div class="tabs tabs-boxed mb-6">
<%= link_to 'Integrations', settings_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_path)}" %>
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_maps_path)}" %>
<% if DawarichSettings.self_hosted? && current_user.admin? %>
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab #{active_tab?(settings_background_jobs_path)}" %>
<%= link_to 'Users', settings_users_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_users_path)}" %>
<%= link_to 'Background Jobs', settings_background_jobs_path, role: 'tab', class: "tab tab-lg #{active_tab?(settings_background_jobs_path)}" %>
<% end %>
<%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab #{active_tab?(settings_maps_path)}" %>
</div>

View file

@ -1,12 +1,9 @@
<% content_for :title, "Background jobs" %>
<div class="min-h-content w-full my-5">
<h1 class="text-3xl font-bold mb-6">Background jobs</h1>
<%= render 'settings/navigation' %>
<div class="flex justify-between items-center mt-5">
<h1 class="font-bold text-4xl">Background jobs</h1>
</div>
<div role="alert" class="alert m-5">
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -1,34 +1,78 @@
<% content_for :title, 'Settings' %>
<div class="min-h-content w-full my-5">
<h1 class="text-3xl font-bold mb-6">User Settings</h1>
<%= render 'settings/navigation' %>
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4">
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5 mx-5">
<h2 class="text-2xl font-bold">Edit your Integrations settings!</h1>
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2">
<%= f.label :immich_url %>
<%= f.text_field :immich_url, value: current_user.safe_settings.immich_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>
<div class="card bg-base-200 shadow-xl">
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="card-body">
<div class="space-y-8 animate-fade-in">
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-camera mr-2 text-primary">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
<circle cx="12" cy="13" r="3"></circle>
</svg>Immich Integration
</h2>
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="form-control w-full">
<%= f.label :immich_url, class: 'label' do %>
<span class="label-text font-medium">Immich URL</span>
<% end %>
<%= f.url_field :immich_url, value: current_user.safe_settings.immich_url, class: "input input-bordered w-full pr-10", placeholder: 'http://192.168.0.1:2283' %>
<span class="label-text-alt mt-1">The base URL of your Immich instance</span>
</div>
<div class="form-control w-full">
<%= f.label :immich_api_key, class: 'label' do %>
<span class="label-text font-medium">Immich API Key</span>
<% end %>
<div class="relative">
<%= f.password_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered w-full pr-10", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<span class="label-text-alt mt-1">Found in your Immich admin panel under API settings</span>
</div>
<%# <div class="flex justify-end">
<button class="btn btn-sm btn-outline">Test Connection</button>
</div> %>
</div>
</div>
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-camera mr-2 text-primary">
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path>
<circle cx="12" cy="13" r="3"></circle>
</svg>Photoprism Integration
</h2>
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="form-control w-full">
<%= f.label :photoprism_url, class: 'label' do %>
<span class="label-text font-medium">Photoprism URL</span>
<% end %>
<div class="relative">
<%= f.url_field :photoprism_url, value: current_user.safe_settings.photoprism_url, class: "input input-bordered w-full pr-10", placeholder: 'http://192.168.0.1:2342' %>
</div>
<span class="label-text-alt mt-1">The base URL of your Photoprism instance</span>
</div>
<div class="form-control w-full">
<%= f.label :photoprism_api_key, class: 'label' do %>
<span class="label-text font-medium">Photoprism API Key</span>
<% end %>
<div class="relative">
<%= f.password_field :photoprism_api_key, value: current_user.safe_settings.photoprism_api_key, class: "input input-bordered w-full pr-10", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<span class="label-text-alt mt-1">Found in your Photoprism settings under Library</span>
</div>
<%# <div class="flex justify-end">
<button class="btn btn-sm btn-outline">Test Connection</button>
</div> %>
</div>
</div>
</div>
<div class="form-control my-2">
<%= f.label :immich_api_key %>
<%= f.text_field :immich_api_key, value: current_user.safe_settings.immich_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
<div class="card-actions justify-end mt-6">
<%= f.submit "Save changes", class: "btn btn-primary" %>
</div>
<div class="divider"></div>
<div class="form-control my-2">
<%= f.label :photoprism_url %>
<%= f.text_field :photoprism_url, value: current_user.safe_settings.photoprism_url, class: "input input-bordered", placeholder: 'http://192.168.0.1:2342' %>
</div>
<div class="form-control my-2">
<%= f.label :photoprism_api_key %>
<%= f.text_field :photoprism_api_key, value: current_user.safe_settings.photoprism_api_key, class: "input input-bordered", placeholder: 'xxxxxxxxxxxxxx' %>
</div>
<div class="form-control my-2">
<%= f.submit "Update", class: "btn btn-primary" %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -1,12 +1,9 @@
<% content_for :title, "Map settings" %>
<div class="min-h-content w-full my-5">
<h1 class="text-3xl font-bold mb-6">Map settings</h1>
<%= render 'settings/navigation' %>
<div class="flex justify-between items-center my-5">
<h1 class="font-bold text-4xl">Maps settings</h1>
</div>
<div role="alert" class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -22,50 +19,106 @@
<span>Please remember, that using a custom tile URL may result in extra costs. Check your map tile provider's terms of service for more information.</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-5" data-controller="map-preview">
<div class="flex flex-col gap-4">
<%= form_for :maps,
url: settings_maps_path,
method: :patch,
autocomplete: "off",
data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2">
<%= f.label :name %>
<%= f.text_field :name, value: @maps['name'], placeholder: 'Example: OpenStreetMap', class: "input input-bordered" %>
<div class="card bg-base-200 shadow-xl">
<%= form_for :maps,
url: settings_maps_path,
method: :patch,
autocomplete: "off",
data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="card-body">
<div class="space-y-8 animate-fade-in">
<div>
<h2 class="text-2xl font-bold mb-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map mr-2 text-primary">
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"></polygon>
<line x1="9" x2="9" y1="3" y2="18"></line>
<line x1="15" x2="15" y1="6" y2="21"></line>
</svg>Map Configuration
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6" data-controller="map-preview">
<div class="bg-base-100 p-5 rounded-lg shadow-sm space-y-4">
<div class="form-control w-full">
<%= f.label :name, class: 'label' do %>
<span class="label-text font-medium">Map Name</span>
<% end %>
<div class="relative">
<%= f.text_field :name, value: @maps['name'], placeholder: 'Example: OpenStreetMap', class: "input input-bordered w-full pr-10" %>
</div>
<span class="label-text-alt mt-1">A descriptive name for your map configuration</span>
</div>
<div class="form-control w-full">
<%= f.label :url, class: 'label' do %>
<span class="label-text font-medium">Tile URL</span>
<% end %>
<div class="relative">
<%= f.text_field :url,
value: @maps['url'],
autocomplete: "off",
placeholder: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
class: "input input-bordered w-full pr-10",
data: {
map_preview_target: "urlInput",
action: "input->map-preview#updatePreview"
} %>
</div>
<span class="label-text-alt mt-1">URL pattern for map tiles. Must include {x}, {y}, and {z} placeholders</span>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start">
<span class="label-text mr-4 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe mr-2 w-4 h-4">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"></path>
<path d="M2 12h20"></path>
</svg>Distance Unit </span>
<div class="flex items-center space-x-2">
<%= f.label :distance_unit_km, 'Kilometers', class: 'cursor-pointer' %>
<%= f.radio_button :distance_unit, 'km', id: 'maps_distance_unit_km', class: 'radio radio-primary ml-1 mr-4', checked: @maps['distance_unit'] == 'km' %>
<%= f.label :distance_unit_mi, 'Miles', class: 'cursor-pointer' %>
<%= f.radio_button :distance_unit, 'mi', id: 'maps_distance_unit_mi', class: 'radio radio-primary ml-1', checked: @maps['distance_unit'] == 'mi' %>
</div>
</label>
</div>
</div>
<div class="bg-base-100 p-5 rounded-lg shadow-sm">
<h3 class="font-semibold mb-2">Map Preview</h3>
<div class="h-[250px] w-full rounded-lg overflow-hidden border border-base-300">
<div class="h-full w-full relative">
<div style="height: 500px;">
<div
data-map-preview-target="mapContainer"
class="w-full h-full rounded-lg border"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="bg-base-100 p-5 mt-5 rounded-lg shadow-sm">
<h3 class="font-semibold mb-4">Tile Usage (Last 7 Days)</h3>
<div class="h-[250px]">
<%= line_chart(
@tile_usage,
height: '200px',
xtitle: 'Days',
ytitle: 'Tiles',
suffix: ' tiles loaded'
) %>
</div>
<div class="mt-4 text-sm text-base-content/70">
<p>Total usage this week: <span class="font-semibold"><%= @tile_usage.sum { |_, count| count } %> tiles</span>
</p>
<!--p>Monthly quota: <span class="font-semibold">100,000 tiles</span-->
</p>
</div>
</div>
</div>
<div class="form-control my-2">
<%= f.label :url, 'URL' %>
<%= f.text_field :url,
value: @maps['url'],
autocomplete: "off",
placeholder: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
class: "input input-bordered",
data: {
map_preview_target: "urlInput",
action: "input->map-preview#updatePreview"
} %>
<div class="card-actions justify-end mt-6">
<%= f.submit 'Save changes', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %>
</div>
<%= f.submit 'Save', class: "btn btn-primary", data: { map_preview_target: "saveButton" } %>
<% end %>
<h2 class="text-lg font-bold">Tile usage</h2>
<%= line_chart(
@tile_usage,
height: '200px',
xtitle: 'Days',
ytitle: 'Tiles',
suffix: ' tiles loaded'
) %>
</div>
<div style="height: 500px;">
<div
data-map-preview-target="mapContainer"
class="w-full h-full rounded-lg border"
></div>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -1,6 +1,7 @@
<% content_for :title, 'Users' %>
<div class="min-h-content w-full">
<div class="min-h-content w-full my-5">
<h1 class="text-3xl font-bold mb-6">Users management</h1>
<%= render 'settings/navigation' %>
<div class="flex flex-col lg:flex-row w-full my-10 space-x-4">

View file

@ -1,14 +1,16 @@
<div class="stat text-center">
<div class="stat-value text-secondary">
<%= number_with_delimiter @points_reverse_geocoded %>
<% if DawarichSettings.store_geodata? %>
<div class="stat text-center">
<div class="stat-value text-secondary">
<%= number_with_delimiter @points_reverse_geocoded %>
</div>
<div class="stat-title">Reverse geocoded points</div>
<div class="stat-title">
<span class="tooltip underline decoration-dotted" data-tip="Points that were reverse geocoded but had no data">
<%= number_with_delimiter @points_reverse_geocoded_without_data %> points without data
</span>
</div>
</div>
<div class="stat-title">Reverse geocoded points</div>
<div class="stat-title">
<span class="tooltip underline decoration-dotted" data-tip="Points that were reverse geocoded but had no data">
<%= number_with_delimiter @points_reverse_geocoded_without_data %> points without data
</span>
</div>
</div>
<% end %>
<div class="stat text-center">
<div class="stat-value text-warning underline hover:no-underline hover:cursor-pointer" onclick="countries_visited.showModal()">

View file

@ -7,11 +7,12 @@
<% end %>
</h2>
<div class="flex items-center gap-2">
<%= link_to '[Update]', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
<div class="gap-2">
<span class='text-xs text-gray-500'>Last update <%= human_date(stat.updated_at) %></span>
<%= link_to '🔄', update_year_month_stats_path(stat.year, stat.month), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
</div>
</div>
<p><%= stat.distance %><%= DISTANCE_UNIT %></p>
<p><%= number_with_delimiter stat.distance %><%= current_user.safe_settings.distance_unit %></p>
<% if DawarichSettings.reverse_geocoding_enabled? %>
<div class="card-actions justify-end">
<%= countries_and_cities_stat_for_month(stat) %>
@ -21,7 +22,7 @@
<%= column_chart(
stat.daily_distance,
height: '100px',
suffix: " #{DISTANCE_UNIT}",
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
ytitle: 'Distance'
) %>

View file

@ -6,7 +6,7 @@
<%= column_chart(
Stat.year_distance(year, current_user),
height: '200px',
suffix: " #{DISTANCE_UNIT}",
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
ytitle: 'Distance'
) %>

View file

@ -4,7 +4,7 @@
<div class="stats stats-vertical lg:stats-horizontal shadow w-full bg-base-200">
<div class="stat text-center">
<div class="stat-value text-primary">
<%= number_with_delimiter(current_user.total_distance) %> <%= DISTANCE_UNIT %>
<%= number_with_delimiter(current_user.total_distance) %> <%= current_user.safe_settings.distance_unit %>
</div>
<div class="stat-title">Total distance</div>
</div>
@ -32,22 +32,59 @@
<%= link_to year, "/stats/#{year}", class: 'underline hover:no-underline' %>
<%= link_to '[Map]', map_url(year_timespan(year)), class: 'underline hover:no-underline' %>
</div>
<%= link_to '[Update]', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
<div class="gap-2">
<span class='text-xs text-gray-500'>Last updated: <%= human_date(stats.first.updated_at) %></span>
<%= link_to '🔄', update_year_month_stats_path(year, :all), data: { turbo_method: :put }, class: 'text-sm text-gray-500 hover:underline' %>
</div>
</h2>
<p>
<% cache [current_user, 'year_distance_stat', year], skip_digest: true do %>
<%= number_with_delimiter year_distance_stat(year, current_user) %><%= DISTANCE_UNIT %>
<%= number_with_delimiter year_distance_stat(year, current_user) %><%= current_user.safe_settings.distance_unit %>
<% end %>
</p>
<% if DawarichSettings.reverse_geocoding_enabled? %>
<div class="card-actions justify-end">
<%= countries_and_cities_stat_for_year(year, stats) %>
<% location_data = countries_and_cities_stat_for_year(year, stats) %>
<%= link_to "#{location_data[:countries_count]} countries, #{location_data[:cities_count]} cities",
"##{location_data[:modal_id]}",
class: "link link-primary",
onclick: "document.getElementById('#{location_data[:modal_id]}').checked = true" %>
<!-- Modal structure -->
<div>
<input type="checkbox" id="<%= location_data[:modal_id] %>" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box max-w-3xl">
<h3 class="text-lg font-bold mb-4">Countries and Cities visited in <%= location_data[:year] %></h3>
<div class="max-h-96 overflow-y-auto">
<% location_data[:grouped_by_country].each do |country, cities| %>
<div class="mb-4">
<h4 class="font-bold">
<span class="mr-2"><%= country_flag(country) %></span>
<%= country %>
</h4>
<% if cities.any? %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 pl-4">
<% cities.each do |city| %>
<div class="text-sm"><%= city %></div>
<% end %>
</div>
<% else %>
<p class="text-sm text-gray-500 italic pl-4">No specific cities recorded</p>
<% end %>
</div>
<% end %>
</div>
</div>
<label class="modal-backdrop" for="<%= location_data[:modal_id] %>"></label>
</div>
</div>
</div>
<% end %>
<%= column_chart(
Stat.year_distance(year, current_user),
height: '200px',
suffix: " #{DISTANCE_UNIT}",
suffix: " #{current_user.safe_settings.distance_unit}",
xtitle: 'Days',
ytitle: 'Distance'
) %>

View file

@ -0,0 +1,14 @@
<% if trip.countries.any? %>
<p class="text-md text-base-content/60">
<%= "#{trip.countries.join(', ')} (#{trip.distance} #{current_user.safe_settings.distance_unit})" %>
</p>
<% elsif trip.visited_countries.present? %>
<p class="text-md text-base-content/60">
<%= "#{trip.visited_countries.join(', ')} (#{trip.distance} #{current_user.safe_settings.distance_unit})" %>
</p>
<% else %>
<p class="text-md text-base-content/60">
<span>Countries are being calculated...</span>
<span class="loading loading-dots loading-sm"></span>
</p>
<% end %>

View file

@ -0,0 +1,6 @@
<% if trip.distance.present? %>
<span class="text-md"><%= trip.distance %> <%= current_user.safe_settings.distance_unit %></span>
<% else %>
<span class="text-md">Calculating...</span>
<span class="loading loading-dots loading-sm"></span>
<% end %>

View file

@ -17,7 +17,6 @@
id='map trips-container'
class="w-full h-full rounded-lg"
data-trips-target="container"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings="<%= current_user.settings.to_json %>"
data-path="<%= trip.path.to_json %>"

View file

@ -0,0 +1,23 @@
<% if trip.path.present? %>
<div
id='map'
class="w-full h-full rounded-lg z-0"
data-controller="trips"
data-trips-target="container"
data-api_key="<%= trip.user.api_key %>"
data-user_settings="<%= trip.user.settings.to_json %>"
data-path="<%= trip.path.coordinates.to_json %>"
data-started_at="<%= trip.started_at %>"
data-ended_at="<%= trip.ended_at %>"
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-trips-target="container" class="h-[25rem] w-full min-h-screen">
</div>
</div>
<% else %>
<div class="flex items-center justify-center h-full">
<div class="text-center">
<p class="text-base-content/60">Trip path is being calculated...</p>
<div class="loading loading-spinner loading-lg mt-4"></div>
</div>
</div>
<% end %>

View file

@ -5,7 +5,7 @@
<span class="hover:underline"><%= trip.name %></span>
</h2>
<p class="text-sm text-gray-600 text-center">
<%= "#{human_date(trip.started_at)} #{human_date(trip.ended_at)}, #{trip.distance} #{DISTANCE_UNIT}" %>
<%= "#{human_date(trip.started_at)} #{human_date(trip.ended_at)}, #{trip.distance} #{current_user.safe_settings.distance_unit}" %>
</p>
<div style="width: 100%; aspect-ratio: 1/1;"
@ -13,11 +13,10 @@
class="rounded-lg z-0"
data-controller="trip-map"
data-trip-map-trip-id-value="<%= trip.id %>"
data-trip-map-path-value="<%= trip.path.to_json %>"
data-trip-map-path-value="<%= trip.path.coordinates.to_json %>"
data-trip-map-api-key-value="<%= current_user.api_key %>"
data-trip-map-user-settings-value="<%= current_user.settings.to_json %>"
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>"
data-trip-map-distance-unit-value="<%= DISTANCE_UNIT %>">
data-trip-map-timezone-value="<%= Rails.configuration.time_zone %>">
</div>
</div>
</div>

View file

@ -1,36 +1,31 @@
<% content_for :title, @trip.name %>
<%= turbo_stream_from "trip_#{@trip.id}" %>
<div class="container mx-auto px-4 max-w-4xl my-5">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2"><%= @trip.name %></h1>
<p class="text-md text-base-content/60">
<%= human_date(@trip.started_at) %> - <%= human_date(@trip.ended_at) %>
</p>
<% if @trip.countries.any? %>
<p class="text-lg text-base-content/60">
<%= "#{@trip.countries.join(', ')} (#{@trip.distance} #{DISTANCE_UNIT})" %>
</p>
<% if @trip.countries.any? || @trip.visited_countries.present? %>
<div id="trip_countries">
<%= render "trips/countries", trip: @trip %>
</div>
<% else %>
<div id="trip_countries">
<p class="text-md text-base-content/60">
<span>Countries are being calculated...</span>
<span class="loading loading-dots loading-sm"></span>
</p>
</div>
<% end %>
</div>
<div class="bg-base-100 my-8 p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="w-full">
<div
id='map'
class="w-full h-full rounded-lg z-0"
data-controller="trips"
data-trips-target="container"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings="<%= current_user.settings.to_json %>"
data-path="<%= @trip.path.to_json %>"
data-started_at="<%= @trip.started_at %>"
data-ended_at="<%= @trip.ended_at %>"
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-trips-target="container" class="h-[25rem] w-full min-h-screen">
</div>
</div>
<div class="w-full" id="trip_path">
<%= render "trips/path", trip: @trip, current_user: current_user %>
</div>
<div class="w-full">
<div>

View file

@ -1,2 +1,6 @@
<%= link_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-success' %>
<%= link_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-error mx-1' %>
<% if !visit.confirmed? %>
<%= link_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-success' %>
<% end %>
<% if !visit.declined? %>
<%= link_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-error mx-1' %>
<% end %>

View file

@ -3,7 +3,14 @@
SELF_HOSTED = ENV.fetch('SELF_HOSTED', 'true') == 'true'
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
DISTANCE_UNIT = ENV.fetch('DISTANCE_UNIT', 'km').to_sym
DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
APP_VERSION = File.read('.app_version').strip
@ -17,6 +24,7 @@ NOMINATIM_API_KEY = ENV.fetch('NOMINATIM_API_KEY', nil)
NOMINATIM_API_USE_HTTPS = ENV.fetch('NOMINATIM_API_USE_HTTPS', 'true') == 'true'
GEOAPIFY_API_KEY = ENV.fetch('GEOAPIFY_API_KEY', nil)
STORE_GEODATA = ENV.fetch('STORE_GEODATA', 'true') == 'true'
# /Reverse geocoding settings
SENTRY_DSN = ENV.fetch('SENTRY_DSN', nil)

View file

@ -1,7 +1,10 @@
# frozen_string_literal: true
class DawarichSettings
BASIC_PAID_PLAN_LIMIT = 10_000_000 # 10 million points
class << self
def reverse_geocoding_enabled?
@reverse_geocoding_enabled ||= photon_enabled? || geoapify_enabled? || nominatim_enabled?
end
@ -32,5 +35,9 @@ class DawarichSettings
def nominatim_enabled?
@nominatim_enabled ||= NOMINATIM_API_HOST.present?
end
def store_geodata?
@store_geodata ||= STORE_GEODATA
end
end
end

View file

@ -2,7 +2,7 @@
settings = {
timeout: 5,
units: DISTANCE_UNIT,
units: :km,
cache: Redis.new,
always_raise: :all,
use_https: PHOTON_API_USE_HTTPS,

View file

@ -6,4 +6,6 @@ Sentry.init do |config|
config.breadcrumbs_logger = [:active_support_logger]
config.dsn = SENTRY_DSN
config.traces_sample_rate = 1.0
config.profiles_sample_rate = 1.0
# config.enable_logs = true
end

View file

@ -3,7 +3,7 @@
class CreatePathsForTrips < ActiveRecord::Migration[8.0]
def up
Trip.find_each do |trip|
Trips::CreatePathJob.perform_later(trip.id)
Trips::CalculatePathJob.perform_later(trip.id)
end
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class SetPointsCountryIds < ActiveRecord::Migration[8.0]
def up
DataMigrations::StartSettingsPointsCountryIdsJob.perform_later
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1 +1 @@
DataMigrate::Data.define(version: 20_250_404_182_629)
DataMigrate::Data.define(version: 20250516181033)

View file

@ -2,6 +2,6 @@
class EnablePostgisExtension < ActiveRecord::Migration[8.0]
def change
enable_extension 'postgis'
enable_extension 'postgis' unless extension_enabled?('postgis')
end
end

View file

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

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class CreateCountries < ActiveRecord::Migration[8.0]
def change
create_table :countries do |t|
t.string :name, null: false
t.string :iso_a2, null: false
t.string :iso_a3, null: false
t.multi_polygon :geom, srid: 4326
t.timestamps
end
add_index :countries, :name
add_index :countries, :iso_a2
add_index :countries, :iso_a3
add_index :countries, :geom, using: :gist
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddCountryIdToPoints < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_reference :points, :country, index: { algorithm: :concurrently }
end
end

465
db/schema.rb generated
View file

@ -10,264 +10,277 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 20_250_404_182_437) do
ActiveRecord::Schema[8.0].define(version: 2025_05_15_192211) do
# These are extensions that must be enabled in order to support this database
enable_extension 'pg_catalog.plpgsql'
enable_extension 'postgis'
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
create_table 'action_text_rich_texts', force: :cascade do |t|
t.string 'name', null: false
t.text 'body'
t.string 'record_type', null: false
t.bigint 'record_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.index %w[record_type record_id name], name: 'index_action_text_rich_texts_uniqueness', unique: true
create_table "action_text_rich_texts", force: :cascade do |t|
t.string "name", null: false
t.text "body"
t.string "record_type", null: false
t.bigint "record_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true
end
create_table 'active_storage_attachments', force: :cascade do |t|
t.string 'name', null: false
t.string 'record_type', null: false
t.bigint 'record_id', null: false
t.bigint 'blob_id', null: false
t.datetime 'created_at', null: false
t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id'
t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness',
unique: true
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
t.bigint "record_id", null: false
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table 'active_storage_blobs', force: :cascade do |t|
t.string 'key', null: false
t.string 'filename', null: false
t.string 'content_type'
t.text 'metadata'
t.string 'service_name', null: false
t.bigint 'byte_size', null: false
t.string 'checksum'
t.datetime 'created_at', null: false
t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true
create_table "active_storage_blobs", force: :cascade do |t|
t.string "key", null: false
t.string "filename", null: false
t.string "content_type"
t.text "metadata"
t.string "service_name", null: false
t.bigint "byte_size", null: false
t.string "checksum"
t.datetime "created_at", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table 'active_storage_variant_records', force: :cascade do |t|
t.bigint 'blob_id', null: false
t.string 'variation_digest', null: false
t.index %w[blob_id variation_digest], name: 'index_active_storage_variant_records_uniqueness', unique: true
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table 'areas', force: :cascade do |t|
t.string 'name', null: false
t.bigint 'user_id', null: false
t.decimal 'longitude', precision: 10, scale: 6, null: false
t.decimal 'latitude', precision: 10, scale: 6, null: false
t.integer 'radius', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.index ['user_id'], name: 'index_areas_on_user_id'
create_table "areas", force: :cascade do |t|
t.string "name", null: false
t.bigint "user_id", null: false
t.decimal "longitude", precision: 10, scale: 6, null: false
t.decimal "latitude", precision: 10, scale: 6, null: false
t.integer "radius", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_areas_on_user_id"
end
create_table 'data_migrations', primary_key: 'version', id: :string, force: :cascade do |t|
create_table "countries", force: :cascade do |t|
t.string "name", null: false
t.string "iso_a2", null: false
t.string "iso_a3", null: false
t.geometry "geom", limit: {srid: 4326, type: "multi_polygon"}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["geom"], name: "index_countries_on_geom", using: :gist
t.index ["iso_a2"], name: "index_countries_on_iso_a2"
t.index ["iso_a3"], name: "index_countries_on_iso_a3"
t.index ["name"], name: "index_countries_on_name"
end
create_table 'exports', force: :cascade do |t|
t.string 'name', null: false
t.string 'url'
t.integer 'status', default: 0, null: false
t.bigint 'user_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.integer 'file_format', default: 0
t.datetime 'start_at'
t.datetime 'end_at'
t.index ['status'], name: 'index_exports_on_status'
t.index ['user_id'], name: 'index_exports_on_user_id'
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end
create_table 'imports', force: :cascade do |t|
t.string 'name', null: false
t.bigint 'user_id', null: false
t.integer 'source', default: 0
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.integer 'raw_points', default: 0
t.integer 'doubles', default: 0
t.integer 'processed', default: 0
t.jsonb 'raw_data'
t.integer 'points_count', default: 0
t.index ['source'], name: 'index_imports_on_source'
t.index ['user_id'], name: 'index_imports_on_user_id'
create_table "exports", force: :cascade do |t|
t.string "name", null: false
t.string "url"
t.integer "status", default: 0, null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "file_format", default: 0
t.datetime "start_at"
t.datetime "end_at"
t.index ["status"], name: "index_exports_on_status"
t.index ["user_id"], name: "index_exports_on_user_id"
end
create_table 'notifications', force: :cascade do |t|
t.string 'title', null: false
t.text 'content', null: false
t.bigint 'user_id', null: false
t.integer 'kind', default: 0, null: false
t.datetime 'read_at'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.index ['kind'], name: 'index_notifications_on_kind'
t.index ['user_id'], name: 'index_notifications_on_user_id'
create_table "imports", force: :cascade do |t|
t.string "name", null: false
t.bigint "user_id", null: false
t.integer "source", default: 0
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "raw_points", default: 0
t.integer "doubles", default: 0
t.integer "processed", default: 0
t.jsonb "raw_data"
t.integer "points_count", default: 0
t.index ["source"], name: "index_imports_on_source"
t.index ["user_id"], name: "index_imports_on_user_id"
end
create_table 'place_visits', force: :cascade do |t|
t.bigint 'place_id', null: false
t.bigint 'visit_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.index ['place_id'], name: 'index_place_visits_on_place_id'
t.index ['visit_id'], name: 'index_place_visits_on_visit_id'
create_table "notifications", force: :cascade do |t|
t.string "title", null: false
t.text "content", null: false
t.bigint "user_id", null: false
t.integer "kind", default: 0, null: false
t.datetime "read_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["kind"], name: "index_notifications_on_kind"
t.index ["user_id"], name: "index_notifications_on_user_id"
end
create_table 'places', force: :cascade do |t|
t.string 'name', null: false
t.decimal 'longitude', precision: 10, scale: 6, null: false
t.decimal 'latitude', precision: 10, scale: 6, null: false
t.string 'city'
t.string 'country'
t.integer 'source', default: 0
t.jsonb 'geodata', default: {}, null: false
t.datetime 'reverse_geocoded_at'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.geography 'lonlat', limit: { srid: 4326, type: 'st_point', geographic: true }
t.index ['lonlat'], name: 'index_places_on_lonlat', using: :gist
create_table "place_visits", force: :cascade do |t|
t.bigint "place_id", null: false
t.bigint "visit_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["place_id"], name: "index_place_visits_on_place_id"
t.index ["visit_id"], name: "index_place_visits_on_visit_id"
end
create_table 'points', force: :cascade do |t|
t.integer 'battery_status'
t.string 'ping'
t.integer 'battery'
t.string 'tracker_id'
t.string 'topic'
t.integer 'altitude'
t.decimal 'longitude', precision: 10, scale: 6
t.string 'velocity'
t.integer 'trigger'
t.string 'bssid'
t.string 'ssid'
t.integer 'connection'
t.integer 'vertical_accuracy'
t.integer 'accuracy'
t.integer 'timestamp'
t.decimal 'latitude', precision: 10, scale: 6
t.integer 'mode'
t.text 'inrids', default: [], array: true
t.text 'in_regions', default: [], array: true
t.jsonb 'raw_data', default: {}
t.bigint 'import_id'
t.string 'city'
t.string 'country'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.bigint 'user_id'
t.jsonb 'geodata', default: {}, null: false
t.bigint 'visit_id'
t.datetime 'reverse_geocoded_at'
t.decimal 'course', precision: 8, scale: 5
t.decimal 'course_accuracy', precision: 8, scale: 5
t.string 'external_track_id'
t.geography 'lonlat', limit: { srid: 4326, type: 'st_point', geographic: true }
t.index ['altitude'], name: 'index_points_on_altitude'
t.index ['battery'], name: 'index_points_on_battery'
t.index ['battery_status'], name: 'index_points_on_battery_status'
t.index ['city'], name: 'index_points_on_city'
t.index ['connection'], name: 'index_points_on_connection'
t.index ['country'], name: 'index_points_on_country'
t.index ['external_track_id'], name: 'index_points_on_external_track_id'
t.index ['geodata'], name: 'index_points_on_geodata', using: :gin
t.index ['import_id'], name: 'index_points_on_import_id'
t.index %w[latitude longitude], name: 'index_points_on_latitude_and_longitude'
t.index %w[lonlat timestamp user_id], name: 'index_points_on_lonlat_timestamp_user_id', unique: true
t.index ['lonlat'], name: 'index_points_on_lonlat', using: :gist
t.index ['reverse_geocoded_at'], name: 'index_points_on_reverse_geocoded_at'
t.index ['timestamp'], name: 'index_points_on_timestamp'
t.index ['trigger'], name: 'index_points_on_trigger'
t.index ['user_id'], name: 'index_points_on_user_id'
t.index ['visit_id'], name: 'index_points_on_visit_id'
create_table "places", force: :cascade do |t|
t.string "name", null: false
t.decimal "longitude", precision: 10, scale: 6, null: false
t.decimal "latitude", precision: 10, scale: 6, null: false
t.string "city"
t.string "country"
t.integer "source", default: 0
t.jsonb "geodata", default: {}, null: false
t.datetime "reverse_geocoded_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
t.index ["lonlat"], name: "index_places_on_lonlat", using: :gist
end
create_table 'stats', force: :cascade do |t|
t.integer 'year', null: false
t.integer 'month', null: false
t.integer 'distance', null: false
t.jsonb 'toponyms'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.bigint 'user_id', null: false
t.jsonb 'daily_distance', default: {}
t.index ['distance'], name: 'index_stats_on_distance'
t.index ['month'], name: 'index_stats_on_month'
t.index ['user_id'], name: 'index_stats_on_user_id'
t.index ['year'], name: 'index_stats_on_year'
create_table "points", force: :cascade do |t|
t.integer "battery_status"
t.string "ping"
t.integer "battery"
t.string "tracker_id"
t.string "topic"
t.integer "altitude"
t.decimal "longitude", precision: 10, scale: 6
t.string "velocity"
t.integer "trigger"
t.string "bssid"
t.string "ssid"
t.integer "connection"
t.integer "vertical_accuracy"
t.integer "accuracy"
t.integer "timestamp"
t.decimal "latitude", precision: 10, scale: 6
t.integer "mode"
t.text "inrids", default: [], array: true
t.text "in_regions", default: [], array: true
t.jsonb "raw_data", default: {}
t.bigint "import_id"
t.string "city"
t.string "country"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "user_id"
t.jsonb "geodata", default: {}, null: false
t.bigint "visit_id"
t.datetime "reverse_geocoded_at"
t.decimal "course", precision: 8, scale: 5
t.decimal "course_accuracy", precision: 8, scale: 5
t.string "external_track_id"
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
t.bigint "country_id"
t.index ["altitude"], name: "index_points_on_altitude"
t.index ["battery"], name: "index_points_on_battery"
t.index ["battery_status"], name: "index_points_on_battery_status"
t.index ["city"], name: "index_points_on_city"
t.index ["connection"], name: "index_points_on_connection"
t.index ["country"], name: "index_points_on_country"
t.index ["country_id"], name: "index_points_on_country_id"
t.index ["external_track_id"], name: "index_points_on_external_track_id"
t.index ["geodata"], name: "index_points_on_geodata", using: :gin
t.index ["import_id"], name: "index_points_on_import_id"
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["trigger"], name: "index_points_on_trigger"
t.index ["user_id"], name: "index_points_on_user_id"
t.index ["visit_id"], name: "index_points_on_visit_id"
end
create_table 'trips', force: :cascade do |t|
t.string 'name', null: false
t.datetime 'started_at', null: false
t.datetime 'ended_at', null: false
t.integer 'distance'
t.bigint 'user_id', null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.geometry 'path', limit: { srid: 3857, type: 'line_string' }
t.index ['user_id'], name: 'index_trips_on_user_id'
create_table "stats", force: :cascade do |t|
t.integer "year", null: false
t.integer "month", null: false
t.integer "distance", null: false
t.jsonb "toponyms"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.jsonb "daily_distance", default: {}
t.index ["distance"], name: "index_stats_on_distance"
t.index ["month"], name: "index_stats_on_month"
t.index ["user_id"], name: "index_stats_on_user_id"
t.index ["year"], name: "index_stats_on_year"
end
create_table 'users', force: :cascade do |t|
t.string 'email', default: '', null: false
t.string 'encrypted_password', default: '', null: false
t.string 'reset_password_token'
t.datetime 'reset_password_sent_at'
t.datetime 'remember_created_at'
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.string 'api_key', default: '', null: false
t.string 'theme', default: 'dark', null: false
t.jsonb 'settings',
default: { 'fog_of_war_meters' => '100', 'meters_between_routes' => '1000',
'minutes_between_routes' => '60' }
t.boolean 'admin', default: false
t.integer 'sign_in_count', default: 0, null: false
t.datetime 'current_sign_in_at'
t.datetime 'last_sign_in_at'
t.string 'current_sign_in_ip'
t.string 'last_sign_in_ip'
t.integer 'status', default: 0
t.datetime 'active_until'
t.index ['email'], name: 'index_users_on_email', unique: true
t.index ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
create_table "trips", force: :cascade do |t|
t.string "name", null: false
t.datetime "started_at", null: false
t.datetime "ended_at", null: false
t.integer "distance"
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.geometry "path", limit: {srid: 3857, type: "line_string"}
t.jsonb "visited_countries", default: {}, null: false
t.index ["user_id"], name: "index_trips_on_user_id"
end
add_check_constraint 'users', 'admin IS NOT NULL', name: 'users_admin_null', validate: false
create_table 'visits', force: :cascade do |t|
t.bigint 'area_id'
t.bigint 'user_id', null: false
t.datetime 'started_at', null: false
t.datetime 'ended_at', null: false
t.integer 'duration', null: false
t.string 'name', null: false
t.integer 'status', default: 0, null: false
t.datetime 'created_at', null: false
t.datetime 'updated_at', null: false
t.bigint 'place_id'
t.index ['area_id'], name: 'index_visits_on_area_id'
t.index ['place_id'], name: 'index_visits_on_place_id'
t.index ['started_at'], name: 'index_visits_on_started_at'
t.index ['user_id'], name: 'index_visits_on_user_id'
create_table "users", force: :cascade do |t|
t.string "email", default: "", null: false
t.string "encrypted_password", default: "", null: false
t.string "reset_password_token"
t.datetime "reset_password_sent_at"
t.datetime "remember_created_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "api_key", default: "", null: false
t.string "theme", default: "dark", null: false
t.jsonb "settings", default: {"fog_of_war_meters" => "100", "meters_between_routes" => "1000", "minutes_between_routes" => "60"}
t.boolean "admin", default: false
t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at"
t.datetime "last_sign_in_at"
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.integer "status", default: 0
t.datetime "active_until"
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
add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id'
add_foreign_key 'active_storage_variant_records', 'active_storage_blobs', column: 'blob_id'
add_foreign_key 'areas', 'users'
add_foreign_key 'notifications', 'users'
add_foreign_key 'place_visits', 'places'
add_foreign_key 'place_visits', 'visits'
add_foreign_key 'points', 'users'
add_foreign_key 'points', 'visits'
add_foreign_key 'stats', 'users'
add_foreign_key 'trips', 'users'
add_foreign_key 'visits', 'areas'
add_foreign_key 'visits', 'places'
add_foreign_key 'visits', 'users'
add_check_constraint "users", "admin IS NOT NULL", name: "users_admin_null", validate: false
create_table "visits", force: :cascade do |t|
t.bigint "area_id"
t.bigint "user_id", null: false
t.datetime "started_at", null: false
t.datetime "ended_at", null: false
t.integer "duration", null: false
t.string "name", null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "place_id"
t.index ["area_id"], name: "index_visits_on_area_id"
t.index ["place_id"], name: "index_visits_on_place_id"
t.index ["started_at"], name: "index_visits_on_started_at"
t.index ["user_id"], name: "index_visits_on_user_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "areas", "users"
add_foreign_key "notifications", "users"
add_foreign_key "place_visits", "places"
add_foreign_key "place_visits", "visits"
add_foreign_key "points", "users"
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
add_foreign_key "trips", "users"
add_foreign_key "visits", "areas"
add_foreign_key "visits", "places"
add_foreign_key "visits", "users"
end

View file

@ -1,14 +1,38 @@
# frozen_string_literal: true
return if User.any?
if User.none?
puts 'Creating user...'
puts 'Creating user...'
User.create!(
email: 'demo@dawarich.app',
password: 'password',
password_confirmation: 'password',
admin: true,
active: true,
active_until: 100.years.from_now
)
User.create!(
email: 'demo@dawarich.app',
password: 'password',
password_confirmation: 'password',
admin: true
)
puts "User created: #{User.first.email} / password: 'password'"
end
puts "User created: #{User.first.email} / password: 'password'"
if Country.none?
puts 'Creating countries...'
countries_json = Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson')))
factory = RGeo::Geos.factory(srid: 4326)
countries_multi_polygon = RGeo::GeoJSON.decode(countries_json.to_json, geo_factory: factory)
ActiveRecord::Base.transaction do
countries_multi_polygon.each do |country, index|
p "Creating #{country.properties['name']}..."
Country.create!(
name: country.properties['name'],
iso_a2: country.properties['ISO3166-1-Alpha-2'],
iso_a3: country.properties['ISO3166-1-Alpha-3'],
geom: country.geometry
)
end
end
end

View file

@ -25,6 +25,7 @@ RUN apk -U add --no-cache \
less \
yaml-dev \
gcompat \
geos \
&& mkdir -p $APP_PATH
# Update gem system and install bundler

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