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 # [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 ## Changed
- Don't check for new version in production. - 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 # [0.29.1] - 2025-07-02

View file

@ -3,10 +3,13 @@
class SettingsController < ApplicationController class SettingsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :authenticate_active_user!, only: %i[update] before_action :authenticate_active_user!, only: %i[update]
def index; end def index; end
def update 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' flash.now[:notice] = 'Settings updated'
@ -31,7 +34,8 @@ class SettingsController < ApplicationController
params.require(:settings).permit( params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters, :meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity, :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
end end

View file

@ -1,19 +1,96 @@
import { showFlashMessage } from "./helpers"; 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) { export function handleAreaCreated(areasLayer, layer, apiKey) {
// Add popup styles
addPopupStyles();
const radius = layer.getRadius(); const radius = layer.getRadius();
const center = layer.getLatLng(); const center = layer.getLatLng();
const formHtml = ` 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"> <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"> <form id="circle-form" class="space-y-4">
<div class="form-control"> <div class="form-control">
<input type="text" <input type="text"
id="circle-name" id="circle-name"
name="area[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" placeholder="Enter area name"
autofocus autofocus
required> required>
@ -23,7 +100,7 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
<input type="hidden" name="area[radius]" value="${radius}"> <input type="hidden" name="area[radius]" value="${radius}">
<div class="flex justify-between mt-4"> <div class="flex justify-between mt-4">
<button type="button" <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()"> onclick="this.closest('.leaflet-popup').querySelector('.leaflet-popup-close-button').click()">
Cancel Cancel
</button> </button>
@ -35,11 +112,14 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
`; `;
layer.bindPopup(formHtml, { layer.bindPopup(formHtml, {
maxWidth: "auto", maxWidth: 400,
minWidth: 300, minWidth: 384,
maxHeight: 600,
closeButton: true, closeButton: true,
closeOnClick: false, closeOnClick: false,
className: 'area-form-popup' className: 'area-form-popup',
autoPan: true,
keepInView: true
}).openPopup(); }).openPopup();
areasLayer.addLayer(layer); areasLayer.addLayer(layer);
@ -69,7 +149,7 @@ export function handleAreaCreated(areasLayer, layer, apiKey) {
e.stopPropagation(); e.stopPropagation();
if (!nameInput.value.trim()) { if (!nameInput.value.trim()) {
nameInput.classList.add('input-error'); nameInput.classList.add('input-error', 'border-error');
return; return;
} }
@ -106,10 +186,29 @@ export function saveArea(formData, areasLayer, layer, apiKey) {
.then(data => { .then(data => {
layer.closePopup(); layer.closePopup();
layer.bindPopup(` layer.bindPopup(`
Name: ${data.name}<br> <div class="card w-80 bg-base-100 border border-base-300 shadow-lg">
Radius: ${Math.round(data.radius)} meters<br> <div class="card-body">
<a href="#" data-id="${data.id}" class="delete-area">[Delete]</a> <h3 class="card-title text-base-content text-lg">${data.name}</h3>
`).openPopup(); <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 // Add event listener for the delete button
layer.on('popupopen', () => { layer.on('popupopen', () => {
@ -151,6 +250,9 @@ export function deleteArea(id, areasLayer, layer, apiKey) {
} }
export function fetchAndDrawAreas(areasLayer, apiKey) { export function fetchAndDrawAreas(areasLayer, apiKey) {
// Add popup styles
addPopupStyles();
fetch(`/api/v1/areas?api_key=${apiKey}`, { fetch(`/api/v1/areas?api_key=${apiKey}`, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -186,20 +288,42 @@ export function fetchAndDrawAreas(areasLayer, apiKey) {
pane: 'areasPane' pane: 'areasPane'
}); });
// Bind popup content // Bind popup content with proper theme-aware styling
const popupContent = ` 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"> <div class="card-body">
<h2 class="card-title">${area.name}</h2> <h2 class="card-title text-base-content text-xl">${area.name}</h2>
<p>Radius: ${Math.round(radius)} meters</p> <div class="space-y-3">
<p>Center: [${lat.toFixed(4)}, ${lng.toFixed(4)}]</p> <div class="stats stats-vertical shadow bg-base-200">
<div class="flex justify-end mt-4"> <div class="stat py-2">
<button class="btn btn-sm btn-error delete-area" data-id="${area.id}">Delete</button> <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> </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 // Add delete button handler when popup opens
circle.on('popupopen', () => { circle.on('popupopen', () => {

View file

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

View file

@ -18,7 +18,8 @@ class Users::SafeSettings
'immich_api_key' => nil, 'immich_api_key' => nil,
'photoprism_url' => nil, 'photoprism_url' => nil,
'photoprism_api_key' => nil, 'photoprism_api_key' => nil,
'maps' => { 'distance_unit' => 'km' } 'maps' => { 'distance_unit' => 'km' },
'visits_suggestions_enabled' => 'true'
}.freeze }.freeze
def initialize(settings = {}) def initialize(settings = {})
@ -43,7 +44,8 @@ class Users::SafeSettings
photoprism_url: photoprism_url, photoprism_url: photoprism_url,
photoprism_api_key: photoprism_api_key, photoprism_api_key: photoprism_api_key,
maps: maps, maps: maps,
distance_unit: distance_unit distance_unit: distance_unit,
visits_suggestions_enabled: visits_suggestions_enabled?
} }
end end
# rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/MethodLength
@ -111,4 +113,8 @@ class Users::SafeSettings
def distance_unit def distance_unit
settings.dig('maps', 'distance_unit') settings.dig('maps', 'distance_unit')
end end
def visits_suggestions_enabled?
settings['visits_suggestions_enabled'] == 'true'
end
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> <span>Spamming many new jobs at once is a bad idea. Let them work or clear the queue beforehand.</span>
</div> </div>
<div class='flex'> <div class='flex flex-wrap'>
<div class="card bg-base-300 w-96 shadow-xl m-5"> <div class="card bg-base-300 w-96 shadow-xl m-5">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">Start Reverse Geocoding</h2> <h2 class="card-title">Start Reverse Geocoding</h2>
@ -48,6 +48,20 @@
<%= link_to 'Open Dashboard', '/sidekiq', target: '_blank', class: 'btn btn-primary' %> <%= link_to 'Open Dashboard', '/sidekiq', target: '_blank', class: 'btn btn-primary' %>
</div> </div>
</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> </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) described_class.perform_now(start_at: custom_start, end_at: custom_end)
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
end end

View file

@ -13,6 +13,10 @@ require 'super_diff/rspec-rails'
require 'rake' require 'rake'
Rails.application.load_tasks 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! # 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 } 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.filter_rails_from_backtrace!
config.include FactoryBot::Syntax::Methods 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.rswag_dry_run = false
config.before(:suite) do
# Ensure Rails routes are loaded for Devise
Rails.application.reload_routes!
end
config.before do config.before do
ActiveJob::Base.queue_adapter = :test ActiveJob::Base.queue_adapter = :test
allow(DawarichSettings).to receive(:store_geodata?).and_return(true) 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 it 'updates the user settings' do
patch '/settings', params: params 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 end
context 'when user is inactive' do context 'when user is inactive' do

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe Users::SafeSettings do RSpec.describe Users::SafeSettings do
describe '#default_settings' do describe '#default_settings' do
context 'with default values' do context 'with default values' do
@ -24,7 +26,8 @@ RSpec.describe Users::SafeSettings do
photoprism_url: nil, photoprism_url: nil,
photoprism_api_key: nil, photoprism_api_key: nil,
maps: { "distance_unit" => "km" }, maps: { "distance_unit" => "km" },
distance_unit: 'km' distance_unit: 'km',
visits_suggestions_enabled: true
} }
) )
end end
@ -47,7 +50,8 @@ RSpec.describe Users::SafeSettings do
'immich_api_key' => 'immich-key', 'immich_api_key' => 'immich-key',
'photoprism_url' => 'https://photoprism.example.com', 'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key', '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 end
let(:safe_settings) { described_class.new(settings) } let(:safe_settings) { described_class.new(settings) }
@ -69,7 +73,32 @@ RSpec.describe Users::SafeSettings do
"immich_api_key" => "immich-key", "immich_api_key" => "immich-key",
"photoprism_url" => "https://photoprism.example.com", "photoprism_url" => "https://photoprism.example.com",
"photoprism_api_key" => "photoprism-key", "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 end
@ -98,6 +127,7 @@ RSpec.describe Users::SafeSettings do
expect(safe_settings.photoprism_url).to be_nil expect(safe_settings.photoprism_url).to be_nil
expect(safe_settings.photoprism_api_key).to be_nil expect(safe_settings.photoprism_api_key).to be_nil
expect(safe_settings.maps).to eq({ "distance_unit" => "km" }) expect(safe_settings.maps).to eq({ "distance_unit" => "km" })
expect(safe_settings.visits_suggestions_enabled?).to be true
end end
end end
@ -118,7 +148,8 @@ RSpec.describe Users::SafeSettings do
'immich_api_key' => 'immich-key', 'immich_api_key' => 'immich-key',
'photoprism_url' => 'https://photoprism.example.com', 'photoprism_url' => 'https://photoprism.example.com',
'photoprism_api_key' => 'photoprism-key', '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 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_url).to eq('https://photoprism.example.com')
expect(safe_settings.photoprism_api_key).to eq('photoprism-key') 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.maps).to eq({ 'name' => 'custom', 'url' => 'https://custom.example.com' })
expect(safe_settings.visits_suggestions_enabled?).to be false
end end
end end
end end

View file

@ -1,22 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
# https://makandracards.com/makandra/37161-rspec-devise-how-to-sign-in-users-in-request-specs # Standard Devise test helpers configuration for 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
RSpec.configure do |config| 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 end

View file

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

View file

@ -14,14 +14,18 @@ describe 'Health API', type: :request do
} }
header 'X-Dawarich-Response', header 'X-Dawarich-Response',
type: :string, schema: {
type: :string,
example: 'Hey, I\'m alive!'
},
required: true, 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!'." 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', header 'X-Dawarich-Version',
type: :string, schema: {
type: :string,
example: '1.0.0'
},
required: true, required: true,
example: '1.0.0',
description: 'The version of the application, for example: 1.0.0' description: 'The version of the application, for example: 1.0.0'
run_test! run_test!

View file

@ -40,99 +40,112 @@ describe 'Overland Batches API', type: :request do
parameter name: :locations, in: :body, schema: { parameter name: :locations, in: :body, schema: {
type: :object, type: :object,
properties: { properties: {
type: { type: :string, example: 'Feature' }, locations: {
geometry: { type: :array,
type: :object, items: {
properties: { type: :object,
type: { type: :string, example: 'Point' }, properties: {
coordinates: { type: :array, example: [13.356718, 52.502397] } 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' }, lon: { type: :number, description: 'Longitude coordinate' },
acc: { type: :number, description: 'Accuracy of position in meters' }, acc: { type: :number, description: 'Accuracy of position in meters' },
bs: { type: :number, description: 'Battery status (0=unknown, 1=unplugged, 2=charging, 3=full)' }, 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' }, BSSID: { type: :string, description: 'Connected WiFi access point MAC address' },
SSID: { type: :string, description: 'Connected WiFi network name' }, SSID: { type: :string, description: 'Connected WiFi network name' },
vac: { type: :number, description: 'Vertical accuracy in meters' }, 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' }, lat: { type: :number, description: 'Latitude coordinate' },
topic: { type: :string, description: 'MQTT topic in format owntracks/user/device' }, topic: { type: :string, description: 'MQTT topic in format owntracks/user/device' },
t: { type: :string, description: 'Type of message (p=position, c=circle, etc)' }, 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' }, isotst: { type: :string, description: 'ISO 8601 timestamp of the location fix' },
disptst: { type: :string, description: 'Human-readable 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' 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 }, timestamp: { type: :number },
latitude: { type: :number }, latitude: { type: :number },
mode: { type: :number }, mode: { type: :number },
inrids: { type: :array }, inrids: { type: :array, items: { type: :string } },
in_regions: { type: :array }, in_regions: { type: :array, items: { type: :string } },
raw_data: { type: :string }, raw_data: { type: :string },
import_id: { type: :string }, import_id: { type: :string },
city: { type: :string }, city: { type: :string },

View file

@ -7,12 +7,22 @@ describe 'Settings API', type: :request do
patch 'Updates user settings' do patch 'Updates user settings' do
request_body_example value: { request_body_example value: {
'settings': { 'settings': {
'route_opacity': 0.3, 'route_opacity': 60,
'meters_between_routes': 100, 'meters_between_routes': 500,
'minutes_between_routes': 100, 'minutes_between_routes': 30,
'fog_of_war_meters': 100, 'fog_of_war_meters': 50,
'time_threshold_minutes': 100, 'time_threshold_minutes': 30,
'merge_threshold_minutes': 100 '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' tags 'Settings'
@ -22,31 +32,95 @@ describe 'Settings API', type: :request do
properties: { properties: {
route_opacity: { route_opacity: {
type: :number, type: :number,
example: 0.3, example: 60,
description: 'the opacity of the route, float between 0 and 1' description: 'Route opacity percentage (0-100)'
}, },
meters_between_routes: { meters_between_routes: {
type: :number, type: :number,
example: 100, example: 500,
description: 'the distance between routes in meters' description: 'Minimum distance between routes in meters'
}, },
minutes_between_routes: { minutes_between_routes: {
type: :number, type: :number,
example: 100, example: 30,
description: 'the time between routes in minutes' description: 'Minimum time between routes in minutes'
}, },
fog_of_war_meters: { fog_of_war_meters: {
type: :number, type: :number,
example: 100, example: 50,
description: 'the fog of war distance in meters' 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' parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'settings updated' do 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 } let(:api_key) { create(:user).api_key }
run_test! run_test!
@ -65,27 +139,91 @@ describe 'Settings API', type: :request do
properties: { properties: {
route_opacity: { route_opacity: {
type: :string, type: :string,
example: 0.3, example: '60',
description: 'the opacity of the route, float between 0 and 1' description: 'Route opacity percentage (0-100)'
}, },
meters_between_routes: { meters_between_routes: {
type: :string, type: :string,
example: 100, example: '500',
description: 'the distance between routes in meters' description: 'Minimum distance between routes in meters'
}, },
minutes_between_routes: { minutes_between_routes: {
type: :string, type: :string,
example: 100, example: '30',
description: 'the time between routes in minutes' description: 'Minimum time between routes in minutes'
}, },
fog_of_war_meters: { fog_of_war_meters: {
type: :string, type: :string,
example: 100, example: '50',
description: 'the fog of war distance in meters' 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 type: string
- name: start_at - name: start_at
in: query in: query
format: date-time schema:
type: string
format: date
required: true required: true
description: Start date in YYYY-MM-DD format description: Start date in YYYY-MM-DD format
example: '2023-01-01' example: '2023-01-01'
schema:
type: string
- name: end_at - name: end_at
in: query in: query
format: date-time schema:
type: string
format: date
required: true required: true
description: End date in YYYY-MM-DD format description: End date in YYYY-MM-DD format
example: '2023-12-31' example: '2023-12-31'
schema:
type: string
responses: responses:
'200': '200':
description: cities found description: cities found
@ -231,17 +231,19 @@ paths:
description: Healthy description: Healthy
headers: headers:
X-Dawarich-Response: X-Dawarich-Response:
type: string schema:
type: string
example: Hey, I'm alive!
required: true required: true
example: Hey, I'm alive!
description: Depending on the authentication status of the request, description: Depending on the authentication status of the request,
the response will be different. If the request is authenticated, the the response will be different. If the request is authenticated, the
response will be 'Hey, I'm alive and authenticated!'. If the request response will be 'Hey, I'm alive and authenticated!'. If the request
is not authenticated, the response will be 'Hey, I'm alive!'. is not authenticated, the response will be 'Hey, I'm alive!'.
X-Dawarich-Version: X-Dawarich-Version:
type: string schema:
type: string
example: 1.0.0
required: true required: true
example: 1.0.0
description: 'The version of the application, for example: 1.0.0' description: 'The version of the application, for example: 1.0.0'
content: content:
application/json: application/json:
@ -273,99 +275,109 @@ paths:
schema: schema:
type: object type: object
properties: properties:
type: locations:
type: string type: array
example: Feature items:
geometry: type: object
type: object properties:
properties: type:
type: type: string
type: string example: Feature
example: Point geometry:
coordinates: type: object
type: array properties:
example: type:
- 13.356718 type: string
- 52.502397 example: Point
properties: coordinates:
type: object type: array
properties: items:
timestamp: type: number
type: string example:
example: '2021-06-01T12:00:00Z' - 13.356718
description: Timestamp in ISO 8601 format - 52.502397
altitude: properties:
type: number type: object
example: 0 properties:
description: Altitude in meters timestamp:
speed: type: string
type: number example: '2021-06-01T12:00:00Z'
example: 0 description: Timestamp in ISO 8601 format
description: Speed in meters per second altitude:
horizontal_accuracy: type: number
type: number example: 0
example: 0 description: Altitude in meters
description: Horizontal accuracy in meters speed:
vertical_accuracy: type: number
type: number example: 0
example: 0 description: Speed in meters per second
description: Vertical accuracy in meters horizontal_accuracy:
motion: type: number
type: array example: 0
example: description: Horizontal accuracy in meters
- walking vertical_accuracy:
- running type: number
- driving example: 0
- cycling description: Vertical accuracy in meters
- stationary motion:
description: 'Motion type, for example: automotive_navigation, type: array
fitness, other_navigation or other' items:
activity: type: string
type: string example:
example: unknown - walking
description: 'Activity type, for example: automotive_navigation, - running
fitness, other_navigation or other' - driving
desired_accuracy: - cycling
type: number - stationary
example: 0 description: 'Motion type, for example: automotive_navigation,
description: Desired accuracy in meters fitness, other_navigation or other'
deferred: activity:
type: number type: string
example: 0 example: unknown
description: the distance in meters to defer location updates description: 'Activity type, for example: automotive_navigation,
significant_change: fitness, other_navigation or other'
type: string desired_accuracy:
example: disabled type: number
description: a significant change mode, disabled, enabled or example: 0
exclusive description: Desired accuracy in meters
locations_in_payload: deferred:
type: number type: number
example: 1 example: 0
description: the number of locations in the payload description: the distance in meters to defer location
device_id: updates
type: string significant_change:
example: 'iOS device #166' type: string
description: the device id example: disabled
unique_id: description: a significant change mode, disabled, enabled
type: string or exclusive
example: '1234567890' locations_in_payload:
description: the device's Unique ID as set by Apple type: number
wifi: example: 1
type: string description: the number of locations in the payload
example: unknown device_id:
description: the WiFi network name type: string
battery_state: example: 'iOS device #166'
type: string description: the device id
example: unknown unique_id:
description: the battery state, unknown, unplugged, charging type: string
or full example: '1234567890'
battery_level: description: the device's Unique ID as set by Apple
type: number wifi:
example: 0 type: string
description: the battery level percentage, from 0 to 1 example: unknown
required: description: the WiFi network name
- geometry battery_state:
- properties 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: examples:
'0': '0':
summary: Creates a batch of points summary: Creates a batch of points
@ -433,6 +445,8 @@ paths:
3=full) 3=full)
inrids: inrids:
type: array type: array
items:
type: string
description: Array of region IDs device is currently in description: Array of region IDs device is currently in
BSSID: BSSID:
type: string type: string
@ -445,6 +459,8 @@ paths:
description: Vertical accuracy in meters description: Vertical accuracy in meters
inregions: inregions:
type: array type: array
items:
type: string
description: Array of region names device is currently in description: Array of region names device is currently in
lat: lat:
type: number type: number
@ -489,7 +505,10 @@ paths:
type: string type: string
description: Human-readable timestamp of the location fix description: Human-readable timestamp of the location fix
required: required:
- owntracks/jane - lat
- lon
- tst
- _type
examples: examples:
'0': '0':
summary: Creates a point summary: Creates a point
@ -805,8 +824,12 @@ paths:
type: number type: number
inrids: inrids:
type: array type: array
items:
type: string
in_regions: in_regions:
type: array type: array
items:
type: string
raw_data: raw_data:
type: string type: string
import_id: import_id:
@ -982,38 +1005,94 @@ paths:
properties: properties:
route_opacity: route_opacity:
type: number type: number
example: 0.3 example: 60
description: the opacity of the route, float between 0 and 1 description: Route opacity percentage (0-100)
meters_between_routes: meters_between_routes:
type: number type: number
example: 100 example: 500
description: the distance between routes in meters description: Minimum distance between routes in meters
minutes_between_routes: minutes_between_routes:
type: number type: number
example: 100 example: 30
description: the time between routes in minutes description: Minimum time between routes in minutes
fog_of_war_meters: fog_of_war_meters:
type: number type: number
example: 100 example: 50
description: the fog of war distance in meters description: Fog of war radius in meters
optional: time_threshold_minutes:
- route_opacity type: number
- meters_between_routes example: 30
- minutes_between_routes description: Time threshold for grouping points in minutes
- fog_of_war_meters merge_threshold_minutes:
- time_threshold_minutes type: number
- merge_threshold_minutes 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: examples:
'0': '0':
summary: Updates user settings summary: Updates user settings
value: value:
settings: settings:
route_opacity: 0.3 route_opacity: 60
meters_between_routes: 100 meters_between_routes: 500
minutes_between_routes: 100 minutes_between_routes: 30
fog_of_war_meters: 100 fog_of_war_meters: 50
time_threshold_minutes: 100 time_threshold_minutes: 30
merge_threshold_minutes: 100 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: get:
summary: Retrieves user settings summary: Retrieves user settings
tags: tags:
@ -1038,28 +1117,73 @@ paths:
properties: properties:
route_opacity: route_opacity:
type: string type: string
example: 0.3 example: '60'
description: the opacity of the route, float between 0 and description: Route opacity percentage (0-100)
1
meters_between_routes: meters_between_routes:
type: string type: string
example: 100 example: '500'
description: the distance between routes in meters description: Minimum distance between routes in meters
minutes_between_routes: minutes_between_routes:
type: string type: string
example: 100 example: '30'
description: the time between routes in minutes description: Minimum time between routes in minutes
fog_of_war_meters: fog_of_war_meters:
type: string type: string
example: 100 example: '50'
description: the fog of war distance in meters description: Fog of war radius in meters
required: time_threshold_minutes:
- route_opacity type: string
- meters_between_routes example: '30'
- minutes_between_routes description: Time threshold for grouping points in minutes
- fog_of_war_meters merge_threshold_minutes:
- time_threshold_minutes type: string
- merge_threshold_minutes 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": "/api/v1/stats":
get: get:
summary: Retrieves all stats summary: Retrieves all stats