Merge pull request #1486 from Freika/feature/disable-visits-suggestion

Feature/disable visits suggestion
This commit is contained in:
Evgenii Burmakin 2025-07-02 23:53:09 +02:00 committed by GitHub
commit fd4b785a19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 811 additions and 324 deletions

View file

@ -6,9 +6,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# [0.29.2] - UNRELEASED
## Added
- In the User Settings -> Background Jobs, you can now enable or disable visits suggestions. It's a background task that runs every day at midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.
## Changed
- Don't check for new version in production.
- Area popup styles are now more consistent.
## Fixed
- Swagger documentation is now valid again.
# [0.29.1] - 2025-07-02

View file

@ -3,10 +3,13 @@
class SettingsController < ApplicationController
before_action :authenticate_user!
before_action :authenticate_active_user!, only: %i[update]
def index; end
def update
current_user.update(settings: settings_params)
existing_settings = current_user.safe_settings.settings
current_user.update(settings: existing_settings.merge(settings_params))
flash.now[:notice] = 'Settings updated'
@ -31,7 +34,8 @@ class SettingsController < ApplicationController
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity,
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
:visits_suggestions_enabled
)
end
end

View file

@ -1,19 +1,96 @@
import { showFlashMessage } from "./helpers";
// Add custom CSS for popup styling
const addPopupStyles = () => {
if (!document.querySelector('#area-popup-styles')) {
const style = document.createElement('style');
style.id = 'area-popup-styles';
style.textContent = `
.area-form-popup,
.area-info-popup {
background: transparent !important;
}
.area-form-popup .leaflet-popup-content-wrapper,
.area-info-popup .leaflet-popup-content-wrapper {
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
border: none !important;
}
.area-form-popup .leaflet-popup-content,
.area-info-popup .leaflet-popup-content {
margin: 0 !important;
padding: 0 1rem 0 0 !important;
background: transparent !important;
border-radius: 1rem !important;
overflow: hidden !important;
width: 100% !important;
max-width: none !important;
}
.area-form-popup .leaflet-popup-tip,
.area-info-popup .leaflet-popup-tip {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.area-form-popup .leaflet-popup,
.area-info-popup .leaflet-popup {
margin-bottom: 0 !important;
}
.area-form-popup .leaflet-popup-close-button,
.area-info-popup .leaflet-popup-close-button {
right: 1.25rem !important;
top: 1.25rem !important;
width: 1.5rem !important;
height: 1.5rem !important;
padding: 0 !important;
color: oklch(var(--bc) / 0.6) !important;
background: oklch(var(--b2)) !important;
border-radius: 0.5rem !important;
border: 1px solid oklch(var(--bc) / 0.2) !important;
font-size: 1rem !important;
font-weight: bold !important;
line-height: 1 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: all 0.2s ease !important;
}
.area-form-popup .leaflet-popup-close-button:hover,
.area-info-popup .leaflet-popup-close-button:hover {
background: oklch(var(--b3)) !important;
color: oklch(var(--bc)) !important;
border-color: oklch(var(--bc) / 0.3) !important;
}
`;
document.head.appendChild(style);
}
};
export function handleAreaCreated(areasLayer, layer, apiKey) {
// Add popup styles
addPopupStyles();
const radius = layer.getRadius();
const center = layer.getLatLng();
const formHtml = `
<div class="card w-96">
<div class="card w-96 bg-base-100 border border-base-300 shadow-xl">
<div class="card-body">
<h2 class="card-title">New Area</h2>
<h2 class="card-title text-gray-500">New Area</h2>
<form id="circle-form" class="space-y-4">
<div class="form-control">
<input type="text"
id="circle-name"
name="area[name]"
class="input input-bordered w-full"
class="input input-bordered input-primary w-full bg-base-200 text-base-content placeholder-base-content/70 border-base-300 focus:border-primary focus:bg-base-100"
placeholder="Enter area name"
autofocus
required>
@ -23,7 +100,7 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
<input type="hidden" name="area[radius]" value="${radius}">
<div class="flex justify-between mt-4">
<button type="button"
class="btn btn-outline"
class="btn btn-outline btn-neutral text-base-content border-base-300 hover:bg-base-200"
onclick="this.closest('.leaflet-popup').querySelector('.leaflet-popup-close-button').click()">
Cancel
</button>
@ -35,11 +112,14 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
`;
layer.bindPopup(formHtml, {
maxWidth: "auto",
minWidth: 300,
maxWidth: 400,
minWidth: 384,
maxHeight: 600,
closeButton: true,
closeOnClick: false,
className: 'area-form-popup'
className: 'area-form-popup',
autoPan: true,
keepInView: true
}).openPopup();
areasLayer.addLayer(layer);
@ -69,7 +149,7 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
e.stopPropagation();
if (!nameInput.value.trim()) {
nameInput.classList.add('input-error');
nameInput.classList.add('input-error', 'border-error');
return;
}
@ -106,10 +186,29 @@ export function saveArea(formData, areasLayer, layer, apiKey) {
.then(data => {
layer.closePopup();
layer.bindPopup(`
Name: ${data.name}<br>
Radius: ${Math.round(data.radius)} meters<br>
<a href="#" data-id="${data.id}" class="delete-area">[Delete]</a>
`).openPopup();
<div class="card w-80 bg-base-100 border border-base-300 shadow-lg">
<div class="card-body">
<h3 class="card-title text-base-content text-lg">${data.name}</h3>
<div class="space-y-2 text-base-content/80">
<p><span class="font-medium text-base-content">Radius:</span> ${Math.round(data.radius)} meters</p>
</div>
<div class="card-actions justify-end mt-4">
<button class="btn btn-sm btn-error delete-area" data-id="${data.id}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
</div>
</div>
`, {
maxWidth: 340,
minWidth: 320,
className: 'area-info-popup',
closeButton: true,
closeOnClick: false
}).openPopup();
// Add event listener for the delete button
layer.on('popupopen', () => {
@ -151,6 +250,9 @@ export function deleteArea(id, areasLayer, layer, apiKey) {
}
export function fetchAndDrawAreas(areasLayer, apiKey) {
// Add popup styles
addPopupStyles();
fetch(`/api/v1/areas?api_key=${apiKey}`, {
method: 'GET',
headers: {
@ -186,20 +288,42 @@ export function fetchAndDrawAreas(areasLayer, apiKey) {
pane: 'areasPane'
});
// Bind popup content
// Bind popup content with proper theme-aware styling
const popupContent = `
<div class="card w-full">
<div class="card w-96 bg-base-100 border border-base-300 shadow-xl">
<div class="card-body">
<h2 class="card-title">${area.name}</h2>
<p>Radius: ${Math.round(radius)} meters</p>
<p>Center: [${lat.toFixed(4)}, ${lng.toFixed(4)}]</p>
<div class="flex justify-end mt-4">
<button class="btn btn-sm btn-error delete-area" data-id="${area.id}">Delete</button>
<h2 class="card-title text-base-content text-xl">${area.name}</h2>
<div class="space-y-3">
<div class="stats stats-vertical shadow bg-base-200">
<div class="stat py-2">
<div class="stat-title text-base-content/70 text-sm">Radius</div>
<div class="stat-value text-base-content text-lg">${Math.round(radius)} meters</div>
</div>
<div class="stat py-2">
<div class="stat-title text-base-content/70 text-sm">Center</div>
<div class="stat-value text-base-content text-sm">[${lat.toFixed(4)}, ${lng.toFixed(4)}]</div>
</div>
</div>
</div>
<div class="card-actions justify-between items-center mt-6">
<div class="badge badge-primary badge-outline">Area ${area.id}</div>
<button class="btn btn-error btn-sm delete-area" data-id="${area.id}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
</div>
</div>
`;
circle.bindPopup(popupContent);
circle.bindPopup(popupContent, {
maxWidth: 400,
minWidth: 384,
className: 'area-info-popup',
closeButton: true,
closeOnClick: false
});
// Add delete button handler when popup opens
circle.on('popupopen', () => {

View file

@ -17,6 +17,7 @@ class BulkVisitsSuggestingJob < ApplicationJob
time_chunks = Visits::TimeChunks.new(start_at:, end_at:).call
users.active.find_each do |user|
next unless user.safe_settings.visits_suggestions_enabled?
next if user.tracked_points.empty?
schedule_chunked_jobs(user, time_chunks)

View file

@ -18,7 +18,8 @@ class Users::SafeSettings
'immich_api_key' => nil,
'photoprism_url' => nil,
'photoprism_api_key' => nil,
'maps' => { 'distance_unit' => 'km' }
'maps' => { 'distance_unit' => 'km' },
'visits_suggestions_enabled' => 'true'
}.freeze
def initialize(settings = {})
@ -43,7 +44,8 @@ class Users::SafeSettings
photoprism_url: photoprism_url,
photoprism_api_key: photoprism_api_key,
maps: maps,
distance_unit: distance_unit
distance_unit: distance_unit,
visits_suggestions_enabled: visits_suggestions_enabled?
}
end
# rubocop:enable Metrics/MethodLength
@ -111,4 +113,8 @@ class Users::SafeSettings
def distance_unit
settings.dig('maps', 'distance_unit')
end
def visits_suggestions_enabled?
settings['visits_suggestions_enabled'] == 'true'
end
end

View file

@ -19,7 +19,7 @@
<span>Spamming many new jobs at once is a bad idea. Let them work or clear the queue beforehand.</span>
</div>
<div class='flex'>
<div class='flex flex-wrap'>
<div class="card bg-base-300 w-96 shadow-xl m-5">
<div class="card-body">
<h2 class="card-title">Start Reverse Geocoding</h2>
@ -48,6 +48,20 @@
<%= link_to 'Open Dashboard', '/sidekiq', target: '_blank', class: 'btn btn-primary' %>
</div>
</div>
</div>
<div class="card bg-base-300 w-96 shadow-xl m-5">
<div class="card-body">
<h2 class="card-title">Visits suggestions</h2>
<p>Enable or disable visits suggestions. It's a background task that runs every day at midnight. Disabling it might be useful if you don't want to receive visits suggestions or if you're using the Dawarich iOS app, which has its own visits suggestions.</p>
<div class="card-actions justify-end">
<% if current_user.safe_settings.visits_suggestions_enabled? %>
<%= link_to 'Disable', settings_path(settings: { 'visits_suggestions_enabled' => false }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-error' %>
<% else %>
<%= link_to 'Enable', settings_path(settings: { 'visits_suggestions_enabled' => true }), method: :patch, data: { confirm: 'Are you sure?', turbo_confirm: 'Are you sure?', turbo_method: :patch }, class: 'btn btn-success' %>
<% end %>
</div>
</div>
</div>
</div>
</div>

View file

@ -102,5 +102,17 @@ RSpec.describe BulkVisitsSuggestingJob, type: :job do
described_class.perform_now(start_at: custom_start, end_at: custom_end)
end
context 'when visits suggestions are disabled' do
before do
allow_any_instance_of(Users::SafeSettings).to receive(:visits_suggestions_enabled?).and_return(false)
end
it 'does not schedule jobs' do
expect(VisitSuggestingJob).not_to receive(:perform_later)
described_class.perform_now
end
end
end
end

View file

@ -13,6 +13,10 @@ require 'super_diff/rspec-rails'
require 'rake'
Rails.application.load_tasks
# Ensure Devise is properly configured for tests
require 'devise'
# Add additional requires below this line. Rails is not loaded until this point!
Dir[Rails.root.join('spec/support/**/*.rb')].sort.each { |f| require f }
@ -32,11 +36,14 @@ RSpec.configure do |config|
config.filter_rails_from_backtrace!
config.include FactoryBot::Syntax::Methods
config.include Devise::Test::IntegrationHelpers, type: :request
config.include Devise::Test::IntegrationHelpers, type: :system
config.rswag_dry_run = false
config.before(:suite) do
# Ensure Rails routes are loaded for Devise
Rails.application.reload_routes!
end
config.before do
ActiveJob::Base.queue_adapter = :test
allow(DawarichSettings).to receive(:store_geodata?).and_return(true)

View file

@ -80,7 +80,9 @@ RSpec.describe 'Settings', type: :request do
it 'updates the user settings' do
patch '/settings', params: params
expect(user.reload.settings).to eq(params[:settings])
user.reload
expect(user.settings['meters_between_routes']).to eq('1000')
expect(user.settings['minutes_between_routes']).to eq('10')
end
context 'when user is inactive' do

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Users::SafeSettings do
describe '#default_settings' do
context 'with default values' do
@ -24,7 +26,8 @@ RSpec.describe Users::SafeSettings do
photoprism_url: nil,
photoprism_api_key: nil,
maps: { "distance_unit" => "km" },
distance_unit: 'km'
distance_unit: 'km',
visits_suggestions_enabled: true
}
)
end
@ -47,7 +50,8 @@ RSpec.describe Users::SafeSettings do
'immich_api_key' => 'immich-key',
'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
'visits_suggestions_enabled' => false
}
end
let(:safe_settings) { described_class.new(settings) }
@ -69,7 +73,32 @@ RSpec.describe Users::SafeSettings do
"immich_api_key" => "immich-key",
"photoprism_url" => "https://photoprism.example.com",
"photoprism_api_key" => "photoprism-key",
"maps" => { "name" => "custom", "url" => "https://custom.example.com" }
"maps" => { "name" => "custom", "url" => "https://custom.example.com" },
"visits_suggestions_enabled" => false
}
)
end
it 'returns custom default_settings configuration' do
expect(safe_settings.default_settings).to eq(
{
fog_of_war_meters: 100,
meters_between_routes: 1000,
preferred_map_layer: "Satellite",
speed_colored_routes: true,
points_rendering_mode: "simplified",
minutes_between_routes: 60,
time_threshold_minutes: 45,
merge_threshold_minutes: 20,
live_map_enabled: false,
route_opacity: 80,
immich_url: "https://immich.example.com",
immich_api_key: "immich-key",
photoprism_url: "https://photoprism.example.com",
photoprism_api_key: "photoprism-key",
maps: { "name" => "custom", "url" => "https://custom.example.com" },
distance_unit: nil,
visits_suggestions_enabled: false
}
)
end
@ -98,6 +127,7 @@ RSpec.describe Users::SafeSettings do
expect(safe_settings.photoprism_url).to be_nil
expect(safe_settings.photoprism_api_key).to be_nil
expect(safe_settings.maps).to eq({ "distance_unit" => "km" })
expect(safe_settings.visits_suggestions_enabled?).to be true
end
end
@ -118,7 +148,8 @@ RSpec.describe Users::SafeSettings do
'immich_api_key' => 'immich-key',
'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key',
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' }
'maps' => { 'name' => 'custom', 'url' => 'https://custom.example.com' },
'visits_suggestions_enabled' => false
}
end
@ -138,6 +169,7 @@ RSpec.describe Users::SafeSettings do
expect(safe_settings.photoprism_url).to eq('https://photoprism.example.com')
expect(safe_settings.photoprism_api_key).to eq('photoprism-key')
expect(safe_settings.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' })
expect(safe_settings.visits_suggestions_enabled?).to be false
end
end
end

View file

@ -1,22 +1,15 @@
# frozen_string_literal: true
# https://makandracards.com/makandra/37161-rspec-devise-how-to-sign-in-users-in-request-specs
module DeviseRequestSpecHelpers
include Warden::Test::Helpers
def sign_in(resource_or_scope, resource = nil)
resource ||= resource_or_scope
scope = Devise::Mapping.find_scope!(resource_or_scope)
login_as(resource, scope:)
end
def sign_out(resource_or_scope)
scope = Devise::Mapping.find_scope!(resource_or_scope)
logout(scope)
end
end
# Standard Devise test helpers configuration for request specs
RSpec.configure do |config|
config.include DeviseRequestSpecHelpers, type: :request
config.include Devise::Test::IntegrationHelpers, type: :request
config.include Devise::Test::IntegrationHelpers, type: :system
# Ensure Devise routes are loaded before request specs
config.before(:each, type: :request) do
# Reload routes to ensure Devise mappings are available
Rails.application.reload_routes! unless @routes_reloaded
@routes_reloaded = true
end
end

View file

@ -17,16 +17,20 @@ RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do
description: 'Your API authentication key'
parameter name: :start_at,
in: :query,
type: :string,
format: 'date-time',
schema: {
type: :string,
format: :date
},
required: true,
description: 'Start date in YYYY-MM-DD format',
example: '2023-01-01'
parameter name: :end_at,
in: :query,
type: :string,
format: 'date-time',
schema: {
type: :string,
format: :date
},
required: true,
description: 'End date in YYYY-MM-DD format',
example: '2023-12-31'

View file

@ -14,14 +14,18 @@ describe 'Health API', type: :request do
}
header 'X-Dawarich-Response',
type: :string,
schema: {
type: :string,
example: 'Hey, I\'m alive!'
},
required: true,
example: 'Hey, I\'m alive!',
description: "Depending on the authentication status of the request, the response will be different. If the request is authenticated, the response will be 'Hey, I'm alive and authenticated!'. If the request is not authenticated, the response will be 'Hey, I'm alive!'."
header 'X-Dawarich-Version',
type: :string,
schema: {
type: :string,
example: '1.0.0'
},
required: true,
example: '1.0.0',
description: 'The version of the application, for example: 1.0.0'
run_test!

View file

@ -40,99 +40,112 @@ describe 'Overland Batches API', type: :request do
parameter name: :locations, in: :body, schema: {
type: :object,
properties: {
type: { type: :string, example: 'Feature' },
geometry: {
type: :object,
properties: {
type: { type: :string, example: 'Point' },
coordinates: { type: :array, example: [13.356718, 52.502397] }
locations: {
type: :array,
items: {
type: :object,
properties: {
type: { type: :string, example: 'Feature' },
geometry: {
type: :object,
properties: {
type: { type: :string, example: 'Point' },
coordinates: {
type: :array,
items: { type: :number },
example: [13.356718, 52.502397]
}
}
},
properties: {
type: :object,
properties: {
timestamp: {
type: :string,
example: '2021-06-01T12:00:00Z',
description: 'Timestamp in ISO 8601 format'
},
altitude: {
type: :number,
example: 0,
description: 'Altitude in meters'
},
speed: {
type: :number,
example: 0,
description: 'Speed in meters per second'
},
horizontal_accuracy: {
type: :number,
example: 0,
description: 'Horizontal accuracy in meters'
},
vertical_accuracy: {
type: :number,
example: 0,
description: 'Vertical accuracy in meters'
},
motion: {
type: :array,
items: { type: :string },
example: %w[walking running driving cycling stationary],
description: 'Motion type, for example: automotive_navigation, fitness, other_navigation or other'
},
activity: {
type: :string,
example: 'unknown',
description: 'Activity type, for example: automotive_navigation, fitness, other_navigation or other'
},
desired_accuracy: {
type: :number,
example: 0,
description: 'Desired accuracy in meters'
},
deferred: {
type: :number,
example: 0,
description: 'the distance in meters to defer location updates'
},
significant_change: {
type: :string,
example: 'disabled',
description: 'a significant change mode, disabled, enabled or exclusive'
},
locations_in_payload: {
type: :number,
example: 1,
description: 'the number of locations in the payload'
},
device_id: {
type: :string,
example: 'iOS device #166',
description: 'the device id'
},
unique_id: {
type: :string,
example: '1234567890',
description: 'the device\'s Unique ID as set by Apple'
},
wifi: {
type: :string,
example: 'unknown',
description: 'the WiFi network name'
},
battery_state: {
type: :string,
example: 'unknown',
description: 'the battery state, unknown, unplugged, charging or full'
},
battery_level: {
type: :number,
example: 0,
description: 'the battery level percentage, from 0 to 1'
}
}
}
},
required: %w[geometry properties]
}
},
properties: {
type: :object,
properties: {
timestamp: {
type: :string,
example: '2021-06-01T12:00:00Z',
description: 'Timestamp in ISO 8601 format'
},
altitude: {
type: :number,
example: 0,
description: 'Altitude in meters'
},
speed: {
type: :number,
example: 0,
description: 'Speed in meters per second'
},
horizontal_accuracy: {
type: :number,
example: 0,
description: 'Horizontal accuracy in meters'
},
vertical_accuracy: {
type: :number,
example: 0,
description: 'Vertical accuracy in meters'
},
motion: {
type: :array,
example: %w[walking running driving cycling stationary],
description: 'Motion type, for example: automotive_navigation, fitness, other_navigation or other'
},
activity: {
type: :string,
example: 'unknown',
description: 'Activity type, for example: automotive_navigation, fitness, other_navigation or other'
},
desired_accuracy: {
type: :number,
example: 0,
description: 'Desired accuracy in meters'
},
deferred: {
type: :number,
example: 0,
description: 'the distance in meters to defer location updates'
},
significant_change: {
type: :string,
example: 'disabled',
description: 'a significant change mode, disabled, enabled or exclusive'
},
locations_in_payload: {
type: :number,
example: 1,
description: 'the number of locations in the payload'
},
device_id: {
type: :string,
example: 'iOS device #166',
description: 'the device id'
},
unique_id: {
type: :string,
example: '1234567890',
description: 'the device\'s Unique ID as set by Apple'
},
wifi: {
type: :string,
example: 'unknown',
description: 'the WiFi network name'
},
battery_state: {
type: :string,
example: 'unknown',
description: 'the battery state, unknown, unplugged, charging or full'
},
battery_level: {
type: :number,
example: 0,
description: 'the battery level percentage, from 0 to 1'
}
},
required: %w[geometry properties]
}
}
}

View file

@ -43,11 +43,11 @@ describe 'OwnTracks Points API', type: :request do
lon: { type: :number, description: 'Longitude coordinate' },
acc: { type: :number, description: 'Accuracy of position in meters' },
bs: { type: :number, description: 'Battery status (0=unknown, 1=unplugged, 2=charging, 3=full)' },
inrids: { type: :array, description: 'Array of region IDs device is currently in' },
inrids: { type: :array, items: { type: :string }, description: 'Array of region IDs device is currently in' },
BSSID: { type: :string, description: 'Connected WiFi access point MAC address' },
SSID: { type: :string, description: 'Connected WiFi network name' },
vac: { type: :number, description: 'Vertical accuracy in meters' },
inregions: { type: :array, description: 'Array of region names device is currently in' },
inregions: { type: :array, items: { type: :string }, description: 'Array of region names device is currently in' },
lat: { type: :number, description: 'Latitude coordinate' },
topic: { type: :string, description: 'MQTT topic in format owntracks/user/device' },
t: { type: :string, description: 'Type of message (p=position, c=circle, etc)' },
@ -63,7 +63,7 @@ describe 'OwnTracks Points API', type: :request do
isotst: { type: :string, description: 'ISO 8601 timestamp of the location fix' },
disptst: { type: :string, description: 'Human-readable timestamp of the location fix' }
},
required: %w[owntracks/jane]
required: %w[lat lon tst _type]
}
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'

View file

@ -39,8 +39,8 @@ describe 'Points API', type: :request do
timestamp: { type: :number },
latitude: { type: :number },
mode: { type: :number },
inrids: { type: :array },
in_regions: { type: :array },
inrids: { type: :array, items: { type: :string } },
in_regions: { type: :array, items: { type: :string } },
raw_data: { type: :string },
import_id: { type: :string },
city: { type: :string },

View file

@ -7,12 +7,22 @@ describe 'Settings API', type: :request do
patch 'Updates user settings' do
request_body_example value: {
'settings': {
'route_opacity': 0.3,
'meters_between_routes': 100,
'minutes_between_routes': 100,
'fog_of_war_meters': 100,
'time_threshold_minutes': 100,
'merge_threshold_minutes': 100
'route_opacity': 60,
'meters_between_routes': 500,
'minutes_between_routes': 30,
'fog_of_war_meters': 50,
'time_threshold_minutes': 30,
'merge_threshold_minutes': 15,
'preferred_map_layer': 'OpenStreetMap',
'speed_colored_routes': false,
'points_rendering_mode': 'raw',
'live_map_enabled': true,
'immich_url': 'https://immich.example.com',
'immich_api_key': 'your-immich-api-key',
'photoprism_url': 'https://photoprism.example.com',
'photoprism_api_key': 'your-photoprism-api-key',
'maps': { 'distance_unit': 'km' },
'visits_suggestions_enabled': true
}
}
tags 'Settings'
@ -22,31 +32,95 @@ describe 'Settings API', type: :request do
properties: {
route_opacity: {
type: :number,
example: 0.3,
description: 'the opacity of the route, float between 0 and 1'
example: 60,
description: 'Route opacity percentage (0-100)'
},
meters_between_routes: {
type: :number,
example: 100,
description: 'the distance between routes in meters'
example: 500,
description: 'Minimum distance between routes in meters'
},
minutes_between_routes: {
type: :number,
example: 100,
description: 'the time between routes in minutes'
example: 30,
description: 'Minimum time between routes in minutes'
},
fog_of_war_meters: {
type: :number,
example: 100,
description: 'the fog of war distance in meters'
example: 50,
description: 'Fog of war radius in meters'
},
time_threshold_minutes: {
type: :number,
example: 30,
description: 'Time threshold for grouping points in minutes'
},
merge_threshold_minutes: {
type: :number,
example: 15,
description: 'Threshold for merging nearby points in minutes'
},
preferred_map_layer: {
type: :string,
example: 'OpenStreetMap',
description: 'Preferred map layer/tile provider'
},
speed_colored_routes: {
type: :boolean,
example: false,
description: 'Whether to color routes based on speed'
},
points_rendering_mode: {
type: :string,
example: 'raw',
description: 'How to render points on the map (raw, heatmap, etc.)'
},
live_map_enabled: {
type: :boolean,
example: true,
description: 'Whether live map updates are enabled'
},
immich_url: {
type: :string,
example: 'https://immich.example.com',
description: 'Immich server URL for photo integration'
},
immich_api_key: {
type: :string,
example: 'your-immich-api-key',
description: 'API key for Immich photo service'
},
photoprism_url: {
type: :string,
example: 'https://photoprism.example.com',
description: 'PhotoPrism server URL for photo integration'
},
photoprism_api_key: {
type: :string,
example: 'your-photoprism-api-key',
description: 'API key for PhotoPrism photo service'
},
maps: {
type: :object,
properties: {
distance_unit: {
type: :string,
example: 'km',
description: 'Distance unit preference (km or miles)'
}
},
description: 'Map-related settings'
},
visits_suggestions_enabled: {
type: :boolean,
example: true,
description: 'Whether visit suggestions are enabled'
}
},
optional: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters
time_threshold_minutes merge_threshold_minutes]
}
}
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'settings updated' do
let(:settings) { { settings: { route_opacity: 0.3 } } }
let(:settings) { { settings: { route_opacity: 60 } } }
let(:api_key) { create(:user).api_key }
run_test!
@ -65,27 +139,91 @@ describe 'Settings API', type: :request do
properties: {
route_opacity: {
type: :string,
example: 0.3,
description: 'the opacity of the route, float between 0 and 1'
example: '60',
description: 'Route opacity percentage (0-100)'
},
meters_between_routes: {
type: :string,
example: 100,
description: 'the distance between routes in meters'
example: '500',
description: 'Minimum distance between routes in meters'
},
minutes_between_routes: {
type: :string,
example: 100,
description: 'the time between routes in minutes'
example: '30',
description: 'Minimum time between routes in minutes'
},
fog_of_war_meters: {
type: :string,
example: 100,
description: 'the fog of war distance in meters'
example: '50',
description: 'Fog of war radius in meters'
},
time_threshold_minutes: {
type: :string,
example: '30',
description: 'Time threshold for grouping points in minutes'
},
merge_threshold_minutes: {
type: :string,
example: '15',
description: 'Threshold for merging nearby points in minutes'
},
preferred_map_layer: {
type: :string,
example: 'OpenStreetMap',
description: 'Preferred map layer/tile provider'
},
speed_colored_routes: {
type: :boolean,
example: false,
description: 'Whether to color routes based on speed'
},
points_rendering_mode: {
type: :string,
example: 'raw',
description: 'How to render points on the map (raw, heatmap, etc.)'
},
live_map_enabled: {
type: :boolean,
example: true,
description: 'Whether live map updates are enabled'
},
immich_url: {
type: :string,
example: 'https://immich.example.com',
description: 'Immich server URL for photo integration'
},
immich_api_key: {
type: :string,
example: 'your-immich-api-key',
description: 'API key for Immich photo service'
},
photoprism_url: {
type: :string,
example: 'https://photoprism.example.com',
description: 'PhotoPrism server URL for photo integration'
},
photoprism_api_key: {
type: :string,
example: 'your-photoprism-api-key',
description: 'API key for PhotoPrism photo service'
},
maps: {
type: :object,
properties: {
distance_unit: {
type: :string,
example: 'km',
description: 'Distance unit preference (km or miles)'
}
},
description: 'Map-related settings'
},
visits_suggestions_enabled: {
type: :boolean,
example: true,
description: 'Whether visit suggestions are enabled'
}
},
required: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters
time_threshold_minutes merge_threshold_minutes]
}
}
}

View file

@ -141,20 +141,20 @@ paths:
type: string
- name: start_at
in: query
format: date-time
schema:
type: string
format: date
required: true
description: Start date in YYYY-MM-DD format
example: '2023-01-01'
schema:
type: string
- name: end_at
in: query
format: date-time
schema:
type: string
format: date
required: true
description: End date in YYYY-MM-DD format
example: '2023-12-31'
schema:
type: string
responses:
'200':
description: cities found
@ -231,17 +231,19 @@ paths:
description: Healthy
headers:
X-Dawarich-Response:
type: string
schema:
type: string
example: Hey, I'm alive!
required: true
example: Hey, I'm alive!
description: Depending on the authentication status of the request,
the response will be different. If the request is authenticated, the
response will be 'Hey, I'm alive and authenticated!'. If the request
is not authenticated, the response will be 'Hey, I'm alive!'.
X-Dawarich-Version:
type: string
schema:
type: string
example: 1.0.0
required: true
example: 1.0.0
description: 'The version of the application, for example: 1.0.0'
content:
application/json:
@ -273,99 +275,109 @@ paths:
schema:
type: object
properties:
type:
type: string
example: Feature
geometry:
type: object
properties:
type:
type: string
example: Point
coordinates:
type: array
example:
- 13.356718
- 52.502397
properties:
type: object
properties:
timestamp:
type: string
example: '2021-06-01T12:00:00Z'
description: Timestamp in ISO 8601 format
altitude:
type: number
example: 0
description: Altitude in meters
speed:
type: number
example: 0
description: Speed in meters per second
horizontal_accuracy:
type: number
example: 0
description: Horizontal accuracy in meters
vertical_accuracy:
type: number
example: 0
description: Vertical accuracy in meters
motion:
type: array
example:
- walking
- running
- driving
- cycling
- stationary
description: 'Motion type, for example: automotive_navigation,
fitness, other_navigation or other'
activity:
type: string
example: unknown
description: 'Activity type, for example: automotive_navigation,
fitness, other_navigation or other'
desired_accuracy:
type: number
example: 0
description: Desired accuracy in meters
deferred:
type: number
example: 0
description: the distance in meters to defer location updates
significant_change:
type: string
example: disabled
description: a significant change mode, disabled, enabled or
exclusive
locations_in_payload:
type: number
example: 1
description: the number of locations in the payload
device_id:
type: string
example: 'iOS device #166'
description: the device id
unique_id:
type: string
example: '1234567890'
description: the device's Unique ID as set by Apple
wifi:
type: string
example: unknown
description: the WiFi network name
battery_state:
type: string
example: unknown
description: the battery state, unknown, unplugged, charging
or full
battery_level:
type: number
example: 0
description: the battery level percentage, from 0 to 1
required:
- geometry
- properties
locations:
type: array
items:
type: object
properties:
type:
type: string
example: Feature
geometry:
type: object
properties:
type:
type: string
example: Point
coordinates:
type: array
items:
type: number
example:
- 13.356718
- 52.502397
properties:
type: object
properties:
timestamp:
type: string
example: '2021-06-01T12:00:00Z'
description: Timestamp in ISO 8601 format
altitude:
type: number
example: 0
description: Altitude in meters
speed:
type: number
example: 0
description: Speed in meters per second
horizontal_accuracy:
type: number
example: 0
description: Horizontal accuracy in meters
vertical_accuracy:
type: number
example: 0
description: Vertical accuracy in meters
motion:
type: array
items:
type: string
example:
- walking
- running
- driving
- cycling
- stationary
description: 'Motion type, for example: automotive_navigation,
fitness, other_navigation or other'
activity:
type: string
example: unknown
description: 'Activity type, for example: automotive_navigation,
fitness, other_navigation or other'
desired_accuracy:
type: number
example: 0
description: Desired accuracy in meters
deferred:
type: number
example: 0
description: the distance in meters to defer location
updates
significant_change:
type: string
example: disabled
description: a significant change mode, disabled, enabled
or exclusive
locations_in_payload:
type: number
example: 1
description: the number of locations in the payload
device_id:
type: string
example: 'iOS device #166'
description: the device id
unique_id:
type: string
example: '1234567890'
description: the device's Unique ID as set by Apple
wifi:
type: string
example: unknown
description: the WiFi network name
battery_state:
type: string
example: unknown
description: the battery state, unknown, unplugged, charging
or full
battery_level:
type: number
example: 0
description: the battery level percentage, from 0 to 1
required:
- geometry
- properties
examples:
'0':
summary: Creates a batch of points
@ -433,6 +445,8 @@ paths:
3=full)
inrids:
type: array
items:
type: string
description: Array of region IDs device is currently in
BSSID:
type: string
@ -445,6 +459,8 @@ paths:
description: Vertical accuracy in meters
inregions:
type: array
items:
type: string
description: Array of region names device is currently in
lat:
type: number
@ -489,7 +505,10 @@ paths:
type: string
description: Human-readable timestamp of the location fix
required:
- owntracks/jane
- lat
- lon
- tst
- _type
examples:
'0':
summary: Creates a point
@ -805,8 +824,12 @@ paths:
type: number
inrids:
type: array
items:
type: string
in_regions:
type: array
items:
type: string
raw_data:
type: string
import_id:
@ -982,38 +1005,94 @@ paths:
properties:
route_opacity:
type: number
example: 0.3
description: the opacity of the route, float between 0 and 1
example: 60
description: Route opacity percentage (0-100)
meters_between_routes:
type: number
example: 100
description: the distance between routes in meters
example: 500
description: Minimum distance between routes in meters
minutes_between_routes:
type: number
example: 100
description: the time between routes in minutes
example: 30
description: Minimum time between routes in minutes
fog_of_war_meters:
type: number
example: 100
description: the fog of war distance in meters
optional:
- route_opacity
- meters_between_routes
- minutes_between_routes
- fog_of_war_meters
- time_threshold_minutes
- merge_threshold_minutes
example: 50
description: Fog of war radius in meters
time_threshold_minutes:
type: number
example: 30
description: Time threshold for grouping points in minutes
merge_threshold_minutes:
type: number
example: 15
description: Threshold for merging nearby points in minutes
preferred_map_layer:
type: string
example: OpenStreetMap
description: Preferred map layer/tile provider
speed_colored_routes:
type: boolean
example: false
description: Whether to color routes based on speed
points_rendering_mode:
type: string
example: raw
description: How to render points on the map (raw, heatmap, etc.)
live_map_enabled:
type: boolean
example: true
description: Whether live map updates are enabled
immich_url:
type: string
example: https://immich.example.com
description: Immich server URL for photo integration
immich_api_key:
type: string
example: your-immich-api-key
description: API key for Immich photo service
photoprism_url:
type: string
example: https://photoprism.example.com
description: PhotoPrism server URL for photo integration
photoprism_api_key:
type: string
example: your-photoprism-api-key
description: API key for PhotoPrism photo service
maps:
type: object
properties:
distance_unit:
type: string
example: km
description: Distance unit preference (km or miles)
description: Map-related settings
visits_suggestions_enabled:
type: boolean
example: true
description: Whether visit suggestions are enabled
examples:
'0':
summary: Updates user settings
value:
settings:
route_opacity: 0.3
meters_between_routes: 100
minutes_between_routes: 100
fog_of_war_meters: 100
time_threshold_minutes: 100
merge_threshold_minutes: 100
route_opacity: 60
meters_between_routes: 500
minutes_between_routes: 30
fog_of_war_meters: 50
time_threshold_minutes: 30
merge_threshold_minutes: 15
preferred_map_layer: OpenStreetMap
speed_colored_routes: false
points_rendering_mode: raw
live_map_enabled: true
immich_url: https://immich.example.com
immich_api_key: your-immich-api-key
photoprism_url: https://photoprism.example.com
photoprism_api_key: your-photoprism-api-key
maps:
distance_unit: km
visits_suggestions_enabled: true
get:
summary: Retrieves user settings
tags:
@ -1038,28 +1117,73 @@ paths:
properties:
route_opacity:
type: string
example: 0.3
description: the opacity of the route, float between 0 and
1
example: '60'
description: Route opacity percentage (0-100)
meters_between_routes:
type: string
example: 100
description: the distance between routes in meters
example: '500'
description: Minimum distance between routes in meters
minutes_between_routes:
type: string
example: 100
description: the time between routes in minutes
example: '30'
description: Minimum time between routes in minutes
fog_of_war_meters:
type: string
example: 100
description: the fog of war distance in meters
required:
- route_opacity
- meters_between_routes
- minutes_between_routes
- fog_of_war_meters
- time_threshold_minutes
- merge_threshold_minutes
example: '50'
description: Fog of war radius in meters
time_threshold_minutes:
type: string
example: '30'
description: Time threshold for grouping points in minutes
merge_threshold_minutes:
type: string
example: '15'
description: Threshold for merging nearby points in minutes
preferred_map_layer:
type: string
example: OpenStreetMap
description: Preferred map layer/tile provider
speed_colored_routes:
type: boolean
example: false
description: Whether to color routes based on speed
points_rendering_mode:
type: string
example: raw
description: How to render points on the map (raw, heatmap,
etc.)
live_map_enabled:
type: boolean
example: true
description: Whether live map updates are enabled
immich_url:
type: string
example: https://immich.example.com
description: Immich server URL for photo integration
immich_api_key:
type: string
example: your-immich-api-key
description: API key for Immich photo service
photoprism_url:
type: string
example: https://photoprism.example.com
description: PhotoPrism server URL for photo integration
photoprism_api_key:
type: string
example: your-photoprism-api-key
description: API key for PhotoPrism photo service
maps:
type: object
properties:
distance_unit:
type: string
example: km
description: Distance unit preference (km or miles)
description: Map-related settings
visits_suggestions_enabled:
type: boolean
example: true
description: Whether visit suggestions are enabled
"/api/v1/stats":
get:
summary: Retrieves all stats