Merge pull request #131 from Freika/zones_and_visits

Areas and visits
This commit is contained in:
Evgenii Burmakin 2024-07-27 14:02:11 +02:00 committed by GitHub
commit 4da8313b51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 1558 additions and 80 deletions

View file

@ -1 +1 @@
0.9.4
0.9.5

View file

@ -1 +0,0 @@
MAPBOX_ACCESS_TOKEN=MAPBOX_ACCESS_TOKEN

View file

@ -1,53 +1,72 @@
# .github/workflows/ruby.yml
name: Ruby
name: CI
# Not functional at the moment
on:
pull_request:
push:
branches: [main]
pull_request:
branches: [main]
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
jobs:
tests:
name: Tests
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:12
image: postgres
env:
POSTGRES_PASSWORD: password
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v2
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
- name: Setup Ruby
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Install packages
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libjemalloc2 libvips postgresql-client libpq-dev
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.2.3
ruby-version: '3.2.3'
bundler-cache: true
- name: Install dependencies
run: |
sudo apt-get update && sudo apt-get -yqq install libpq-dev
bundle install --jobs 4 --retry 3
- name: Run Tests
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16' # Use the appropriate Node.js version
- name: Install Node.js dependencies
run: npm install
- name: Install Ruby dependencies
run: bundle install
- name: Run tests
env:
RAILS_ENV: test
PGHOST: localhost
DISABLE_SPRING: 1
DATABASE_URL: postgres://postgres:postgres@localhost:5432
REDIS_URL: redis://localhost:6379/1
run: |
cp config/database.ci.yml config/database.yml
bundle exec rails db:schema:load
bundle exec rspec --format progress
bin/rails db:setup
bin/rails spec || (cat log/test.log && exit 1)
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: ${{ github.workspace }}/tmp/capybara
if-no-files-found: ignore
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1 +1 @@
ruby-3
3.2.3

View file

@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.9.5] — 2024-07-27
### Added
- A possibility to create areas. To create an area, click on the Areas checkbox in map controls (top right corner of the map), then in the top left corner of the map, click on a small circle icon. This will enable draw tool, allowing you to draw an area. When you finish drawing, release the mouse button, and the area will be created. Click on the area, set the name and click "Save" to save the area. You can also delete the area by clicking on the trash icon in the area popup.
- A background job to calculate your visits. This job will calculate your visits based on the areas you've created.
- Visits page. This page will show you all your visits, calculated based on the areas you've created. You can see the date and time of the visit, the area you've visited, and the duration of the visit.
- A possibility to confirm or decline a visit. When you create an area, the visit is not calculated immediately. You need to confirm or decline the visit. You can do this on the Visits page. Click on the visit, then click on the "Confirm" or "Decline" button. If you confirm the visit, it will be added to your timeline. If you decline the visit, it will be removed from your timeline.
- Settings for visit calculation. You can set the minimum time spent in the area to consider it as a visit. This setting can be found in the Settings page.
- POST `/api/v1/areas` and GET `/api/v1/areas` endpoints. You can now create and list your areas via the API.
⚠️ Visits functionality is still in beta. If you find any issues, please let me know. ⚠️
### Fixed
- A route popup now correctly shows distance made in the route, not the distance between first and last points in the route.
---
## [0.9.4] — 2024-07-21
### Added

View file

@ -10,6 +10,7 @@ gem 'data_migrate'
gem 'devise'
gem 'geocoder'
gem 'importmap-rails'
gem 'kaminari'
gem 'lograge'
gem 'oj'
gem 'pg'
@ -26,7 +27,6 @@ gem 'stimulus-rails'
gem 'tailwindcss-rails'
gem 'turbo-rails'
gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]
gem 'will_paginate', '~> 4.0'
group :development, :test do
gem 'debug', platforms: %i[mri mingw x64_mingw]

View file

@ -151,6 +151,18 @@ GEM
json (2.7.2)
json-schema (4.3.0)
addressable (>= 2.8)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
kaminari-activerecord (= 1.2.2)
kaminari-core (= 1.2.2)
kaminari-actionview (1.2.2)
actionview
kaminari-core (= 1.2.2)
kaminari-activerecord (1.2.2)
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
language_server-protocol (3.17.0.3)
lograge (0.14.0)
actionpack (>= 4)
@ -389,7 +401,6 @@ GEM
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
will_paginate (4.0.1)
zeitwerk (2.6.16)
PLATFORMS
@ -413,6 +424,7 @@ DEPENDENCIES
foreman
geocoder
importmap-rails
kaminari
lograge
oj
pg
@ -439,7 +451,6 @@ DEPENDENCIES
turbo-rails
tzinfo-data
webmock
will_paginate (~> 4.0)
RUBY VERSION
ruby 3.2.3p157

File diff suppressed because one or more lines are too long

View file

@ -19,3 +19,7 @@
text-align: center;
line-height: 36px; /* Same as font-size for perfect centering */
}
.timeline-box {
overflow: visible !important;
}

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
class Api::V1::AreasController < ApplicationController
before_action :authenticate_api_key
before_action :set_area, only: %i[update destroy]
def index
@areas = current_api_user.areas
render json: @areas, status: :ok
end
def create
@area = current_api_user.areas.build(area_params)
if @area.save
render json: @area, status: :created
else
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @area.update(area_params)
render json: @area, status: :ok
else
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@area.destroy!
render json: { message: 'Area was successfully deleted' }, status: :ok
end
private
def set_area
@area = current_api_user.areas.find(params[:id])
end
def area_params
params.require(:area).permit(:name, :latitude, :longitude, :radius)
end
end

View file

@ -4,7 +4,7 @@ class Api::V1::PointsController < ApplicationController
before_action :authenticate_user!
def destroy
point = current_user.points.find(params[:id])
point = current_user.tracked_points.find(params[:id])
point.destroy
render json: { message: 'Point deleted successfully' }

View file

@ -6,7 +6,7 @@ class NotificationsController < ApplicationController
def index
@notifications =
current_user.notifications.order(created_at: :desc).paginate(page: params[:page], per_page: 20)
current_user.notifications.order(created_at: :desc).page(params[:page]).per(20)
end
def show

View file

@ -10,7 +10,8 @@ class PointsController < ApplicationController
.without_raw_data
.where(timestamp: start_at..end_at)
.order(timestamp: :desc)
.paginate(page: params[:page], per_page: 50)
.page(params[:page])
.per(50)
@start_at = Time.zone.at(start_at)
@end_at = Time.zone.at(end_at)

View file

@ -30,7 +30,8 @@ class SettingsController < ApplicationController
def settings_params
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
)
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class VisitsController < ApplicationController
before_action
before_action :set_visit, only: %i[update]
def index
visits = current_user
.visits
.where(status: :pending)
.or(current_user.visits.where(status: :confirmed))
.order(started_at: :asc)
.group_by { |visit| visit.started_at.to_date }
.map { |k, v| { date: k, visits: v } }
@visits = Kaminari.paginate_array(visits).page(params[:page]).per(10)
end
def update
if @visit.update(visit_params)
redirect_to visits_url, notice: 'Visit was successfully updated.', status: :see_other
else
render :edit, status: :unprocessable_entity
end
end
private
def set_visit
@visit = current_user.visits.find(params[:id])
end
def visit_params
params.require(:visit).permit(:name, :started_at, :ended_at, :status)
end
end

View file

@ -10,17 +10,9 @@ module ApplicationHelper
end
end
def month_timespan(stat)
month = DateTime.new(stat.year, stat.month).in_time_zone(Time.zone)
start_at = month.beginning_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
end_at = month.end_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
{ start_at:, end_at: }
end
def year_timespan(year)
start_at = Time.utc(year).in_time_zone('Europe/Berlin').beginning_of_year.strftime('%Y-%m-%dT%H:%M')
end_at = Time.utc(year).in_time_zone('Europe/Berlin').end_of_year.strftime('%Y-%m-%dT%H:%M')
start_at = Time.utc(year).in_time_zone(Time.zone).beginning_of_year.strftime('%Y-%m-%dT%H:%M')
end_at = Time.utc(year).in_time_zone(Time.zone).end_of_year.strftime('%Y-%m-%dT%H:%M')
{ start_at:, end_at: }
end

View file

@ -9,6 +9,7 @@ import { haversineDistance } from "../maps/helpers";
import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers";
import { addTileLayer } from "../maps/layers";
import "leaflet-draw";
export default class extends Controller {
static targets = ["container"];
@ -16,6 +17,7 @@ export default class extends Controller {
connect() {
console.log("Map controller connected");
this.apiKey = this.element.dataset.api_key;
this.markers = JSON.parse(this.element.dataset.coordinates);
this.timezone = this.element.dataset.timezone;
this.clearFogRadius = this.element.dataset.fog_of_war_meters;
@ -33,12 +35,14 @@ export default class extends Controller {
this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone);
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer
this.areasLayer = L.layerGroup(); // Initialize areas layer
const controlsLayer = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer // Add the areas layer to the controls
};
L.control
@ -52,6 +56,9 @@ export default class extends Controller {
L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Fetch and draw areas when the map is loaded
this.fetchAndDrawAreas();
let fogEnabled = false;
// Hide fog by default
@ -83,6 +90,22 @@ export default class extends Controller {
addTileLayer(this.map);
this.addLastMarker(this.map, this.markers);
this.addEventListeners();
// Initialize Leaflet.draw
this.initializeDrawControl();
// Add event listeners to toggle draw controls
this.map.on('overlayadd', (e) => {
if (e.name === 'Areas') {
this.map.addControl(this.drawControl);
}
});
this.map.on('overlayremove', (e) => {
if (e.name === 'Areas') {
this.map.removeControl(this.drawControl);
}
});
}
disconnect() {
@ -211,24 +234,26 @@ export default class extends Controller {
fog.appendChild(circle);
}
addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone) {
addHighlightOnHover(polyline, map, polylineCoordinates, timezone) {
const originalStyle = { color: "blue", opacity: 0.6, weight: 3 };
const highlightStyle = { color: "yellow", opacity: 1, weight: 5 };
polyline.setStyle(originalStyle);
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
const firstTimestamp = new Date(startPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const lastTimestamp = new Date(endPoint[4] * 1000).toLocaleString("en-GB", { timeZone: timezone });
const minutes = Math.round((endPoint[4] - startPoint[4]) / 60);
const timeOnRoute = minutesToDaysHoursMinutes(minutes);
const distance = haversineDistance(startPoint[0], startPoint[1], endPoint[0], endPoint[1]);
const distanceToPrev = prevPoint ? haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]) : "N/A";
const distanceToNext = nextPoint ? haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]) : "N/A";
const timeBetweenPrev = prevPoint ? Math.round((startPoint[4] - prevPoint[4]) / 60) : "N/A";
const timeBetweenNext = nextPoint ? Math.round((nextPoint[4] - endPoint[4]) / 60) : "N/A";
const totalDistance = polylineCoordinates.reduce((acc, curr, index, arr) => {
if (index === 0) return acc;
const dist = haversineDistance(arr[index - 1][0], arr[index - 1][1], curr[0], curr[1]);
return acc + dist;
}, 0);
const startIcon = L.divIcon({ html: "🚥", className: "emoji-icon" });
const finishIcon = L.divIcon({ html: "🏁", className: "emoji-icon" });
@ -239,10 +264,18 @@ export default class extends Controller {
<b>Start:</b> ${firstTimestamp}<br>
<b>End:</b> ${lastTimestamp}<br>
<b>Duration:</b> ${timeOnRoute}<br>
<b>Distance:</b> ${formatDistance(distance)}<br>
<b>Total Distance:</b> ${formatDistance(totalDistance)}<br>
`;
if (isDebugMode) {
const prevPoint = polylineCoordinates[0];
const nextPoint = polylineCoordinates[polylineCoordinates.length - 1];
const distanceToPrev = haversineDistance(prevPoint[0], prevPoint[1], startPoint[0], startPoint[1]);
const distanceToNext = haversineDistance(endPoint[0], endPoint[1], nextPoint[0], nextPoint[1]);
const timeBetweenPrev = Math.round((startPoint[4] - prevPoint[4]) / 60);
const timeBetweenNext = Math.round((endPoint[4] - nextPoint[4]) / 60);
popupContent += `
<b>Prev Route:</b> ${Math.round(distanceToPrev)}m and ${minutesToDaysHoursMinutes(timeBetweenPrev)} away<br>
<b>Next Route:</b> ${Math.round(distanceToNext)}m and ${minutesToDaysHoursMinutes(timeBetweenNext)} away<br>
@ -319,15 +352,212 @@ export default class extends Controller {
const latLngs = polylineCoordinates.map((point) => [point[0], point[1]]);
const polyline = L.polyline(latLngs, { color: "blue", opacity: 0.6, weight: 3 });
const startPoint = polylineCoordinates[0];
const endPoint = polylineCoordinates[polylineCoordinates.length - 1];
const prevPoint = index > 0 ? splitPolylines[index - 1][splitPolylines[index - 1].length - 1] : null;
const nextPoint = index < splitPolylines.length - 1 ? splitPolylines[index + 1][0] : null;
this.addHighlightOnHover(polyline, map, startPoint, endPoint, prevPoint, nextPoint, timezone);
this.addHighlightOnHover(polyline, map, polylineCoordinates, timezone);
return polyline;
})
).addTo(map);
}
initializeDrawControl() {
// Initialize the FeatureGroup to store editable layers
this.drawnItems = new L.FeatureGroup();
this.map.addLayer(this.drawnItems);
// Initialize the draw control and pass it the FeatureGroup of editable layers
this.drawControl = new L.Control.Draw({
draw: {
polyline: false,
polygon: false,
rectangle: false,
marker: false,
circlemarker: false,
circle: {
shapeOptions: {
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5,
},
},
},
});
// Handle circle creation
this.map.on(L.Draw.Event.CREATED, (event) => {
const layer = event.layer;
if (event.layerType === 'circle') {
this.handleCircleCreated(layer);
}
this.drawnItems.addLayer(layer);
});
}
handleCircleCreated(layer) {
const radius = layer.getRadius();
const center = layer.getLatLng();
const formHtml = `
<div class="card w-96 max-w-sm bg-content-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">New Area</h2>
<form id="circle-form">
<div class="form-control">
<label for="circle-name" class="label">
<span class="label-text">Name</span>
</label>
<input type="text" id="circle-name" name="area[name]" class="input input-bordered input-ghost focus:input-ghost w-full max-w-xs" required>
</div>
<input type="hidden" name="area[latitude]" value="${center.lat}">
<input type="hidden" name="area[longitude]" value="${center.lng}">
<input type="hidden" name="area[radius]" value="${radius}">
<div class="card-actions justify-end mt-4">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
`;
layer.bindPopup(
formHtml, {
maxWidth: "auto",
minWidth: 300
}
).openPopup();
layer.on('popupopen', () => {
const form = document.getElementById('circle-form');
form.addEventListener('submit', (e) => {
e.preventDefault();
this.saveCircle(new FormData(form), layer);
});
});
// Add the layer to the areas layer group
this.areasLayer.addLayer(layer);
}
saveCircle(formData, layer, apiKey) {
const data = {};
formData.forEach((value, key) => {
const keys = key.split('[').map(k => k.replace(']', ''));
if (keys.length > 1) {
if (!data[keys[0]]) data[keys[0]] = {};
data[keys[0]][keys[1]] = value;
} else {
data[keys[0]] = value;
}
});
fetch(`"/api/v1/areas?api_key=${apiKey}"`, {
method: 'POST',
headers: { 'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Circle saved:', data);
layer.closePopup();
layer.bindPopup(`
Name: ${data.name}<br>
Radius: ${Math.round(data.radius)} meters<br>
<a href="#" data-id="${marker[6]}" class="delete-area">[Delete]</a>
`).openPopup();
// Add event listener for the delete button
layer.on('popupopen', () => {
document.querySelector('.delete-area').addEventListener('click', () => {
this.deleteArea(data.id, layer);
});
});
})
.catch(error => {
console.error('There was a problem with the save request:', error);
});
}
deleteArea(id, layer) {
fetch(`/api/v1/areas/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Area deleted:', data);
this.areasLayer.removeLayer(layer); // Remove the layer from the areas layer group
})
.catch(error => {
console.error('There was a problem with the delete request:', error);
});
}
fetchAndDrawAreas() {
fetch('/api/v1/areas', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Fetched areas:', data); // Debugging line to check response
data.forEach(area => {
// Log each area to verify the structure
console.log('Area:', area);
// Check if necessary fields are present
if (area.latitude && area.longitude && area.radius && area.name && area.id) {
const layer = L.circle([area.latitude, area.longitude], {
radius: area.radius,
color: 'red',
fillColor: '#f03',
fillOpacity: 0.5
}).bindPopup(`
Name: ${area.name}<br>
Radius: ${Math.round(area.radius)} meters<br>
<a href="#" data-id="${area.id}" class="delete-area">[Delete]</a>
`);
this.areasLayer.addLayer(layer); // Add to areas layer group
console.log('Added layer to areasLayer:', layer); // Debugging line to confirm addition
// Add event listener for the delete button
layer.on('popupopen', () => {
document.querySelector('.delete-area').addEventListener('click', (e) => {
e.preventDefault();
if (confirm('Are you sure you want to delete this area?')) {
this.deleteArea(area.id, layer);
}
});
});
} else {
console.error('Area missing required fields:', area);
}
});
})
.catch(error => {
console.error('There was a problem with the fetch request:', error);
});
}
}

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class AreaVisitsCalculatingJob < ApplicationJob
queue_as :default
def perform(user_id)
user = User.find(user_id)
areas = user.areas
Visits::Areas::Calculate(user, areas).call
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AreaVisitsCalculationSchedulingJob < ApplicationJob
queue_as :default
def perform
User.find_each { AreaVisitsCalculatingJob.perform_later(_1.id) }
end
end

8
app/models/area.rb Normal file
View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Area < ApplicationRecord
belongs_to :user
has_many :visits, dependent: :destroy
validates :name, :latitude, :longitude, :radius, presence: true
end

View file

@ -4,6 +4,7 @@ class Point < ApplicationRecord
reverse_geocoded_by :latitude, :longitude
belongs_to :import, optional: true
belongs_to :visit, optional: true
belongs_to :user
validates :latitude, :longitude, :timestamp, presence: true

View file

@ -12,6 +12,8 @@ class User < ApplicationRecord
has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :exports, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :areas, dependent: :destroy
has_many :visits, dependent: :destroy
after_create :create_api_key

11
app/models/visit.rb Normal file
View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Visit < ApplicationRecord
belongs_to :area
belongs_to :user
has_many :points, dependent: :nullify
validates :started_at, :ended_at, :duration, :name, :status, presence: true
enum status: { pending: 0, confirmed: 1, declined: 2 }
end

24
app/models/visit_draft.rb Normal file
View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class VisitDraft
attr_accessor :start_time, :end_time, :points
def initialize(start_time)
@start_time = start_time
@end_time = start_time
@points = []
end
def add_point(point)
@points << point
@end_time = point.timestamp if point.timestamp > @end_time
end
def duration_in_minutes
(end_time - start_time) / 60.0
end
def valid?
@points.size > 1 && duration_in_minutes >= 10
end
end

View file

@ -0,0 +1,83 @@
# frozen_string_literal: true
class Areas::Visits::Create
attr_reader :user, :areas
def initialize(user, areas)
@user = user
@areas = areas
@time_threshold_minutes = 30 || user.settings['time_threshold_minutes']
@merge_threshold_minutes = 15 || user.settings['merge_threshold_minutes']
end
def call
areas.map { area_visits(_1) }
end
private
def area_visits(area)
points_grouped_by_month = area_points(area)
visits_by_month = group_points_by_month(points_grouped_by_month)
visits_by_month.each do |month, visits|
Rails.logger.info("Month: #{month}, Total visits: #{visits.size}")
visits.each do |time_range, visit_points|
create_or_update_visit(area, time_range, visit_points)
end
end
end
def area_points(area)
area_radius_in_km = area.radius / 1000.0
points = Point.where(user_id: user.id)
.near([area.latitude, area.longitude], area_radius_in_km)
.order(timestamp: :asc)
# check if all points within the area are assigned to a visit
points.group_by { |point| Time.zone.at(point.timestamp).strftime('%Y-%m') }
end
def group_points_by_month(points)
visits_by_month = {}
points.each do |month, points_in_month|
visits_by_month[month] = Visits::Group.new(
time_threshold_minutes: @time_threshold_minutes,
merge_threshold_minutes: @merge_threshold_minutes
).call(points_in_month)
end
visits_by_month
end
def create_or_update_visit(area, time_range, visit_points)
Rails.logger.info("Visit from #{time_range}, Points: #{visit_points.size}")
ActiveRecord::Base.transaction do
visit = find_or_initialize_visit(area.id, visit_points.first.timestamp)
visit.tap do |v|
v.name = "#{area.name}, #{time_range}"
v.ended_at = Time.zone.at(visit_points.last.timestamp)
v.duration = (visit_points.last.timestamp - visit_points.first.timestamp) / 60
v.status = :pending
end
visit.save!
visit_points.each { _1.update!(visit_id: visit.id) }
end
end
def find_or_initialize_visit(area_id, timestamp)
Visit.find_or_initialize_by(
area_id:,
user_id: user.id,
started_at: Time.zone.at(timestamp)
)
end
end

127
app/services/visitcalc.rb Normal file
View file

@ -0,0 +1,127 @@
# frozen_string_literal: true
class Visitcalc
class Visit
attr_accessor :start_time, :end_time, :points
def initialize(start_time)
@start_time = start_time
@end_time = start_time
@points = []
end
def add_point(point)
@points << point
@end_time = point.timestamp if point.timestamp > @end_time
end
def duration_in_minutes
(end_time - start_time) / 60.0
end
def valid?
@points.size > 1 && duration_in_minutes >= 10
end
end
def call
# Usage
area = Area.last
points = Point.near([area.latitude, area.longitude], (area.radius / 1000.0)).order(timestamp: :asc)
points_grouped_by_month = points.group_by { |point| Time.zone.at(point.timestamp).strftime('%Y-%m') }
visits_by_month = {}
points_grouped_by_month.each do |month, points_in_month|
visits_by_month[month] = group_points_into_visits(points_in_month, 30, 15)
end
# Debugging output to check the number of visits and some sample data
visits_by_month.each do |month, visits|
puts "Month: #{month}, Total visits: #{visits.size}"
visits.each do |time_range, visit_points|
puts "Visit from #{time_range}, Points: #{visit_points.size}"
end
end
visits_by_month.map { |d, v| v.keys }
end
def group_points_into_visits(points, time_threshold_minutes = 30, merge_threshold_minutes = 15)
# Ensure points are sorted by timestamp
sorted_points = points.sort_by(&:timestamp)
visits = []
current_visit = nil
sorted_points.each do |point|
point_time = point.timestamp
puts "Processing point at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
if current_visit.nil?
puts "Starting new visit at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
current_visit = Visit.new(point_time)
current_visit.add_point(point)
else
time_difference = (point_time - current_visit.end_time) / 60.0 # Convert to minutes
puts "Time difference: #{time_difference.round} minutes"
if time_difference <= time_threshold_minutes
current_visit.add_point(point)
else
if current_visit.valid?
puts "Ending visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')}, duration: #{current_visit.duration_in_minutes} minutes, points: #{current_visit.points.size}"
visits << current_visit
else
puts "Discarding visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')} (invalid, points: #{current_visit.points.size}, duration: #{current_visit.duration_in_minutes} minutes)"
end
current_visit = Visit.new(point_time)
current_visit.add_point(point)
puts "Starting new visit at #{Time.zone.at(point_time).strftime('%Y-%m-%d %H:%M:%S')}"
end
end
end
# Add the last visit to the list if it is valid
if current_visit&.valid?
puts "Ending visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')}, duration: #{current_visit.duration_in_minutes} minutes, points: #{current_visit.points.size}"
visits << current_visit
else
puts "Discarding last visit from #{Time.zone.at(current_visit.start_time).strftime('%Y-%m-%d %H:%M:%S')} to #{Time.zone.at(current_visit.end_time).strftime('%Y-%m-%d %H:%M:%S')} (invalid, points: #{current_visit.points.size}, duration: #{current_visit.duration_in_minutes} minutes)"
end
# Merge visits that are not more than merge_threshold_minutes apart
merged_visits = []
previous_visit = nil
visits.each do |visit|
if previous_visit.nil?
previous_visit = visit
else
time_difference = (visit.start_time - previous_visit.end_time) / 60.0 # Convert to minutes
if time_difference <= merge_threshold_minutes
previous_visit.points.concat(visit.points)
previous_visit.end_time = visit.end_time
else
merged_visits << previous_visit
previous_visit = visit
end
end
end
merged_visits << previous_visit if previous_visit
# Sort visits by start time
merged_visits.sort_by!(&:start_time)
# Convert visits to a hash with human-readable datetime ranges as keys and points as values
visits_hash = {}
merged_visits.each do |visit|
start_time_str = Time.zone.at(visit.start_time).strftime('%Y-%m-%d %H:%M:%S')
end_time_str = Time.zone.at(visit.end_time).strftime('%Y-%m-%d %H:%M:%S')
visits_hash["#{start_time_str} - #{end_time_str}"] = visit.points
end
visits_hash
end
end
# Run the Visitcalc class
# Visitcalc.new.call

View file

@ -5,7 +5,7 @@ class Visits::Calculate
@points = points
end
def call
def city_visits
normalize_result(city_visits)
end

View file

@ -0,0 +1,130 @@
# frozen_string_literal: true
class Visits::Group
def initialize(time_threshold_minutes: 30, merge_threshold_minutes: 15)
@time_threshold_minutes = time_threshold_minutes
@merge_threshold_minutes = merge_threshold_minutes
@visits = []
@current_visit = nil
end
def call(points)
process_points(points.sort_by(&:timestamp))
finalize_current_visit
merge_visits
convert_to_hash
end
private
def process_points(sorted_points)
sorted_points.each { process_point(_1) }
end
def process_point(point)
point_time = point.timestamp
log_point_processing(point_time)
@current_visit.nil? ? start_new_visit(point_time, point) : handle_existing_visit(point_time, point)
end
def start_new_visit(point_time, point)
log_new_visit(point_time)
@current_visit = VisitDraft.new(point_time)
@current_visit.add_point(point)
end
def handle_existing_visit(point_time, point)
time_difference = calculate_time_difference(point_time)
log_time_difference(time_difference)
if time_difference <= @time_threshold_minutes
@current_visit.add_point(point)
else
finalize_current_visit
start_new_visit(point_time, point)
end
end
def calculate_time_difference(point_time)
(point_time - @current_visit.end_time) / 60.0
end
def finalize_current_visit
return if @current_visit.nil?
if @current_visit.valid?
log_valid_visit
@visits << @current_visit
else
log_invalid_visit
end
@current_visit = nil
end
def merge_visits
merged_visits = []
previous_visit = nil
@visits.each do |visit|
if previous_visit.nil?
previous_visit = visit
else
time_difference = (visit.start_time - previous_visit.end_time) / 60.0
if time_difference <= @merge_threshold_minutes
merge_visit(previous_visit, visit)
else
merged_visits << previous_visit
previous_visit = visit
end
end
end
merged_visits << previous_visit if previous_visit
@visits = merged_visits.sort_by(&:start_time)
end
def merge_visit(previous_visit, current_visit)
previous_visit.points.concat(current_visit.points)
previous_visit.end_time = current_visit.end_time
end
def convert_to_hash
@visits.each_with_object({}) do |visit, hash|
hash[format_time_range(visit)] = visit.points
end
end
def format_time_range(visit)
start_time = format_time(visit.start_time)
end_time = format_time(visit.end_time)
"#{start_time} - #{end_time}"
end
def format_time(timestamp)
Time.zone.at(timestamp).strftime('%Y-%m-%d %H:%M')
end
def log_point_processing(point_time)
Rails.logger.info("Processing point at #{format_time(point_time)}")
end
def log_new_visit(point_time)
Rails.logger.info("Starting new visit at #{format_time(point_time)}")
end
def log_time_difference(time_difference)
Rails.logger.info("Time difference: #{time_difference.round} minutes")
end
def log_valid_visit
Rails.logger.info("Ending visit from #{format_time(@current_visit.start_time)} to #{format_time(@current_visit.end_time)}, duration: #{@current_visit.duration_in_minutes} minutes, points: #{@current_visit.points.size}") # rubocop:disable Layout/LineLength
end
def log_invalid_visit
Rails.logger.info("Discarding visit from #{format_time(@current_visit.start_time)} to #{format_time(@current_visit.end_time)} (invalid, points: #{@current_visit.points.size}, duration: #{@current_visit.duration_in_minutes} minutes)") # rubocop:disable Layout/LineLength
end
end

View file

@ -8,6 +8,7 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

View file

@ -41,6 +41,7 @@
<div
class="w-full"
data-api_key="<%= current_user.api_key %>"
data-controller="maps"
data-coordinates="<%= @coordinates %>"
data-timezone="<%= Rails.configuration.time_zone %>"

View file

@ -6,7 +6,7 @@
<%= link_to "Mark all as read", mark_notifications_as_read_path, method: :post, data: { turbo_method: :post }, class: "btn btn-sm btn-primary" %>
</div>
<div class="mb-4">
<%= will_paginate @notifications %>
<%= paginate @notifications %>
</div>
</div>
<div id="notifications" class="w-full max-w-2xl">

View file

@ -29,7 +29,7 @@
<% end %>
<div class='text-center my-5'>
<%= will_paginate @points %>
<%= paginate @points %>
</div>
<div id="points" class="min-w-full">

View file

@ -80,6 +80,66 @@
<% end %>
<%= f.number_field :fog_of_war_meters, value: current_user.settings['fog_of_war_meters'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :time_threshold_minutes do %>
Visit time threshold
<!-- The button to open modal -->
<label for="time_threshold_minutes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="time_threshold_minutes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Visit time threshold</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
This value is the threshold, based on which a visit is calculated. If the time between two consequent points is greater than this value, the visit is considered a new visit. If the time between two points is less than this value, the visit is considered as a continuation of the previous visit.
</p>
<p class="py-4">
For example, if you set this value to 30 minutes, and you have four points with a time difference of 20 minutes between them, they will be considered as one visit. If the time difference between two first points is 20 minutes, and between third and fourth point is 40 minutes, the visit will be split into two visits.
</p>
<p class="py-4">
Default value is 30 minutes.
</p>
</div>
<label class="modal-backdrop" for="time_threshold_minutes_info">Close</label>
</div>
<% end %>
<%= f.number_field :time_threshold_minutes, value: current_user.settings['time_threshold_minutes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :merge_threshold_minutes do %>
Merge time threshold
<!-- The button to open modal -->
<label for="merge_threshold_minutes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="merge_threshold_minutes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Merge threshold</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
This value is the threshold, based on which two visits are merged into one. If the time between two consequent visits is less than this value, the visits are merged into one visit. If the time between two visits is greater than this value, the visits are considered as separate visits.
</p>
<p class="py-4">
For example, if you set this value to 30 minutes, and you have two visits with a time difference of 20 minutes between them, they will be merged into one visit. If the time difference between two visits is 40 minutes, the visits will be considered as separate visits.
</p>
<p class="py-4">
Default value is 15 minutes.
</p>
</div>
<label class="modal-backdrop" for="merge_threshold_minutes_info">Close</label>
</div>
<% end %>
<%= f.number_field :merge_threshold_minutes, value: current_user.settings['merge_threshold_minutes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.submit "Update", class: "btn btn-primary" %>
</div>

View file

@ -8,6 +8,7 @@
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url, class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
@ -41,6 +42,7 @@
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url, class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>

View file

@ -38,10 +38,25 @@
<h2 class="text-lg font-semibold mt-5">
<%= country[:country] %> (<%= country[:cities].count %> cities)
</h2>
<ul>
<ul class="timeline timeline-vertical">
<% country[:cities].each do |city| %>
<li>
<%= city[:city] %> (<%= link_to_date(city[:timestamp]) %>)
<hr />
<div class="timeline-start"><%= link_to_date(city[:timestamp]) %></div>
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
</div>
<div class="timeline-end timeline-box"><%= city[:city] %></div>
<hr />
</li>
<% end %>
</ul>

View file

@ -1,7 +1,7 @@
<div id="<%= dom_id stat %>" class="card w-full bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title">
<%= link_to map_url(month_timespan(stat)), class: "underline hover:no-underline text-#{header_colors.sample}" do %>
<%= link_to map_url(timespan(stat.month, stat.year)), class: "underline hover:no-underline text-#{header_colors.sample}" do %>
<%= "#{Date::MONTHNAMES[stat.month]} of #{stat.year}" %>
<% end %>
</h2>

View file

@ -0,0 +1,56 @@
<div class="w-full">
<% content_for :title, "Visits" %>
<div class="flex justify-center my-5">
<h1 class="font-bold text-4xl">Visits</h1>
</div>
<div class="flex justify-center my-5">
<div class='flex'>
<%= paginate @visits %>
</div>
</div>
<ul class="timeline timeline-snap-icon max-md:timeline-compact timeline-vertical">
<% @visits.each.with_index do |date, index| %>
<li>
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="<%= date[:visits].all?(&:confirmed?) ? 'green' : 'currentColor' %>"
class="h-5 w-5">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd" />
</svg>
</div>
<div class="<%= index.odd? ? 'timeline-start' : 'timeline-end' %> mb-10 md:text-end">
<time class="font-mono italic"><%= date[:date].strftime('%A, %d %B %Y') %></time>
<% date[:visits].each do |visit| %>
<div class="group relative">
<div class="flex items-center justify-between">
<div>
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.pending? %>">
<%= visit.area.name %>
</div>
<div>
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
</div>
</div>
<% if visit.pending? %>
<div class="opacity-0 transition-opacity duration-300 group-hover:opacity-100 flex items-center ml-4">
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success mr-1' %>
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error' %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<hr />
</li>
<% end %>
</ul>
</div>

View file

@ -16,3 +16,4 @@ pin 'leaflet-providers' # @2.0.0
pin 'chartkick', to: 'chartkick.js'
pin 'Chart.bundle', to: 'Chart.bundle.js'
pin 'leaflet.heat' # @0.2.0
pin "leaflet-draw" # @1.0.4

View file

@ -26,6 +26,7 @@ Rails.application.routes.draw do
post 'settings/generate_api_key', to: 'settings#generate_api_key', as: :generate_api_key
resources :imports
resources :visits, only: %i[index update]
resources :exports, only: %i[index create destroy]
resources :points, only: %i[index] do
collection do
@ -54,6 +55,7 @@ Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :areas, only: %i[index create update destroy]
resources :points, only: %i[destroy]
namespace :overland do

View file

@ -4,3 +4,8 @@ stat_creating_job:
cron: "0 */6 * * *"
class: "StatCreatingJob"
queue: default
area_visits_calculation_scheduling_job:
cron: "0 * * * *" # every hour
class: "AreaVisitsCalculationSchedulingJob"
queue: default

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddVisitSettingsToUser < ActiveRecord::Migration[7.1]
def up
User.find_each do |user|
user.settings = user.settings.merge(
time_threshold_minutes: 30,
merge_threshold_minutes: 15
)
user.save!
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
class CreatePoints < ActiveRecord::Migration[7.1]
def change
create_table :points do |t|

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateAreas < ActiveRecord::Migration[7.1]
def change
create_table :areas do |t|
t.string :name, null: false
t.references :user, null: false, foreign_key: true
t.decimal :longitude, precision: 10, scale: 6, null: false
t.decimal :latitude, precision: 10, scale: 6, null: false
t.integer :radius, null: false
t.timestamps
end
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateVisits < ActiveRecord::Migration[7.1]
def change
create_table :visits do |t|
t.references :area, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
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, null: false, default: 0
t.timestamps
end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddVisitIdToPoints < ActiveRecord::Migration[7.1]
def change
add_reference :points, :visit, foreign_key: true
end
end

34
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -42,7 +42,15 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
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 "exports", force: :cascade do |t|
@ -110,6 +118,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
t.datetime "updated_at", null: false
t.bigint "user_id"
t.jsonb "geodata", default: {}, null: false
t.bigint "visit_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"
@ -122,6 +131,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
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 "stats", force: :cascade do |t|
@ -149,15 +159,33 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
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"=>"200", "meters_between_routes"=>"1000", "minutes_between_routes"=>"60"}
t.jsonb "settings", default: {"fog_of_war_meters"=>"100", "meters_between_routes"=>"1000", "minutes_between_routes"=>"60"}
t.boolean "admin", default: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
create_table "visits", force: :cascade do |t|
t.bigint "area_id", null: false
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.index ["area_id"], name: "index_visits_on_area_id"
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 "points", "users"
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
add_foreign_key "visits", "areas"
add_foreign_key "visits", "users"
end

View file

@ -5,7 +5,8 @@ return if User.any?
User.create!(
email: 'user@domain.com',
password: 'password',
password_confirmation: 'password'
password_confirmation: 'password',
admin: true
)
puts "User created: #{User.first.email} / password: 'password'"

11
spec/factories/areas.rb Normal file
View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :area do
name { 'Adlershof' }
user
latitude { 52.437 }
longitude { 13.539 }
radius { 100 }
end
end

13
spec/factories/visits.rb Normal file
View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
FactoryBot.define do
factory :visit do
area
user
started_at { Time.zone.now }
ended_at { Time.zone.now + 1.hour }
duration { 1.hour }
name { 'Visit' }
status { 'pending' }
end
end

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe AreaVisitsCalculatingJob, type: :job do
pending "add some examples to (or delete) #{__FILE__}"
end

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe AreaVisitsCalculationSchedulingJob, type: :job do
pending "add some examples to (or delete) #{__FILE__}"
end

21
spec/models/area_spec.rb Normal file
View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Area, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:visits).dependent(:destroy) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:latitude) }
it { is_expected.to validate_presence_of(:longitude) }
it { is_expected.to validate_presence_of(:radius) }
end
describe 'factory' do
it { expect(build(:area)).to be_valid }
end
end

View file

@ -10,6 +10,8 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
it { is_expected.to have_many(:exports).dependent(:destroy) }
it { is_expected.to have_many(:notifications).dependent(:destroy) }
it { is_expected.to have_many(:areas).dependent(:destroy) }
it { is_expected.to have_many(:visits).dependent(:destroy) }
end
describe 'callbacks' do

15
spec/models/visit_spec.rb Normal file
View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visit, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:area) }
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:points).dependent(:nullify) }
end
describe 'factory' do
it { expect(build(:visit)).to be_valid }
end
end

View file

@ -0,0 +1,100 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/api/v1/areas', type: :request do
let(:user) { create(:user) }
describe 'GET /index' do
it 'renders a successful response' do
get api_v1_areas_url(api_key: user.api_key)
expect(response).to be_successful
end
end
describe 'POST /create' do
context 'with valid parameters' do
let(:valid_attributes) do
attributes_for(:area)
end
it 'creates a new Area' do
expect do
post api_v1_areas_url(api_key: user.api_key), params: { area: valid_attributes }
end.to change(Area, :count).by(1)
end
it 'redirects to the created api_v1_area' do
post api_v1_areas_url(api_key: user.api_key), params: { area: valid_attributes }
expect(response).to have_http_status(:created)
end
end
context 'with invalid parameters' do
let(:invalid_attributes) do
attributes_for(:area, name: nil)
end
it 'does not create a new Area' do
expect do
post api_v1_areas_url(api_key: user.api_key), params: { area: invalid_attributes }
end.to change(Area, :count).by(0)
end
it 'renders a response with 422 status' do
post api_v1_areas_url(api_key: user.api_key), params: { area: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'PATCH /update' do
context 'with valid parameters' do
let(:area) { create(:area, user:) }
let(:new_attributes) { attributes_for(:area).merge(name: 'New Name') }
it 'updates the requested api_v1_area' do
patch api_v1_area_url(area, api_key: user.api_key), params: { area: new_attributes }
area.reload
expect(area.reload.name).to eq('New Name')
end
it 'redirects to the api_v1_area' do
patch api_v1_area_url(area, api_key: user.api_key), params: { area: new_attributes }
area.reload
expect(response).to have_http_status(:ok)
end
end
context 'with invalid parameters' do
let(:area) { create(:area, user:) }
let(:invalid_attributes) { attributes_for(:area, name: nil) }
it 'renders a response with 422 status' do
patch api_v1_area_url(area, api_key: user.api_key), params: { area: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe 'DELETE /destroy' do
let!(:area) { create(:area, user:) }
it 'destroys the requested api_v1_area' do
expect do
delete api_v1_area_url(area, api_key: user.api_key)
end.to change(Area, :count).by(-1)
end
it 'redirects to the api_v1_areas list' do
delete api_v1_area_url(area, api_key: user.api_key)
expect(response).to have_http_status(:ok)
end
end
end

View file

@ -25,7 +25,9 @@ RSpec.describe '/settings/users', type: :request do
end
context 'when user is an admin' do
before { sign_in create(:user, :admin) }
let!(:admin) { create(:user, :admin) }
before { sign_in admin }
describe 'POST /create' do
context 'with valid parameters' do

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/visits', type: :request do
let(:user) { create(:user) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
sign_in user
end
describe 'GET /index' do
it 'renders a successful response' do
get visits_url
expect(response).to be_successful
end
end
describe 'PATCH /update' do
context 'with valid parameters' do
let(:visit) { create(:visit, user:, status: :pending) }
it 'confirms the requested visit' do
patch visit_url(visit), params: { visit: { status: :confirmed } }
expect(visit.reload.status).to eq('confirmed')
end
it 'rejects the requested visit' do
patch visit_url(visit), params: { visit: { status: :declined } }
expect(visit.reload.status).to eq('declined')
end
it 'redirects to the visit index page' do
patch visit_url(visit), params: { visit: { status: :confirmed } }
expect(response).to redirect_to(visits_url)
end
end
end
end

View file

@ -0,0 +1,109 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Areas::Visits::Create do
describe '#call' do
let(:user) { create(:user) }
let(:home_area) { create(:area, user:, latitude: 0, longitude: 0, radius: 100) }
let(:work_area) { create(:area, user:, latitude: 1, longitude: 1, radius: 100) }
subject(:create_visits) { described_class.new(user, [home_area, work_area]).call }
context 'when there are no points' do
it 'does not create visits' do
expect { create_visits }.not_to(change { Visit.count })
end
it 'does not log any visits' do
expect(Rails.logger).not_to receive(:info)
create_visits
end
end
context 'when there are points' do
let(:home_visit_date) { DateTime.new(2021, 1, 1, 10, 0, 0, Time.zone.formatted_offset) }
let!(:home_point1) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date) }
let!(:home_point2) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 10.minutes) }
let!(:home_point3) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 20.minutes) }
let(:work_visit_date) { DateTime.new(2021, 1, 1, 12, 0, 0, Time.zone.formatted_offset) }
let!(:work_point1) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date) }
let!(:work_point2) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date + 10.minutes) }
let!(:work_point3) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date + 20.minutes) }
it 'creates visits' do
expect { create_visits }.to change { Visit.count }.by(2)
end
it 'creates visits with correct points' do
create_visits
home_visit = Visit.find_by(area_id: home_area.id)
work_visit = Visit.find_by(area_id: work_area.id)
expect(home_visit.points).to match_array([home_point1, home_point2, home_point3])
expect(work_visit.points).to match_array([work_point1, work_point2, work_point3])
end
context 'when there are points outside the time threshold' do
let(:home_point4) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 40.minutes) }
it 'does not create visits' do
expect { create_visits }.to change { Visit.count }.by(2)
end
it 'does not include points outside the time threshold' do
create_visits
home_visit = Visit.find_by(area_id: home_area.id)
work_visit = Visit.find_by(area_id: work_area.id)
expect(home_visit.points).to match_array([home_point1, home_point2, home_point3])
expect(work_visit.points).to match_array([work_point1, work_point2, work_point3])
end
end
context 'when there are visits already' do
let!(:home_visit) do
create(:visit,
user:,
started_at: Time.zone.at(home_point1.timestamp),
name: 'Home',
area: home_area,
points: [home_point1, home_point2])
end
let!(:work_visit) do
create(:visit,
user:,
started_at: Time.zone.at(work_point1.timestamp),
name: 'Work',
area: work_area,
points: [work_point1, work_point2])
end
it 'does not create new visits' do
expect { create_visits }.not_to(change { Visit.count })
end
it 'updates existing visits' do
create_visits
home_visit = Visit.find_by(area_id: home_area.id)
work_visit = Visit.find_by(area_id: work_area.id)
expect(home_visit.points).to match_array([home_point1, home_point2, home_point3])
expect(work_visit.points).to match_array([work_point1, work_point2, work_point3])
end
end
context 'running twice' do
it 'does not create duplicate visits' do
create_visits
expect { create_visits }.not_to(change { Visit.count })
end
end
end
end
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'swagger_helper'
describe 'Areas API', type: :request do
path '/api/v1/areas' do
post 'Creates an area' do
request_body_example value: {
'name': 'Home',
'latitude': 40.7128,
'longitude': -74.0060,
'radius': 100
}
tags 'Areas'
consumes 'application/json'
parameter name: :area, in: :body, schema: {
type: :object,
properties: {
name: { type: :string },
latitude: { type: :number },
longitude: { type: :number },
radius: { type: :number }
},
required: %w[name latitude longitude radius]
}
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '201', 'area created' do
let(:area) { { name: 'Home', latitude: 40.7128, longitude: -74.0060, radius: 100 } }
let(:api_key) { create(:user).api_key }
run_test!
end
response '422', 'invalid request' do
let(:area) { { name: 'Home', latitude: 40.7128, longitude: -74.0060 } }
let(:api_key) { create(:user).api_key }
run_test!
end
end
get 'Retrieves all areas' do
tags 'Areas'
produces 'application/json'
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'areas found' do
schema type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
name: { type: :string },
latitude: { type: :number },
longitude: { type: :number },
radius: { type: :number }
},
required: %w[id name latitude longitude radius]
}
let(:user) { create(:user) }
let(:areas) { create_list(:area, 3, user:) }
let(:api_key) { user.api_key }
run_test!
end
end
end
end

View file

@ -4,6 +4,87 @@ info:
title: API V1
version: v1
paths:
"/api/v1/areas":
post:
summary: Creates an area
tags:
- Areas
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
responses:
'201':
description: area created
'422':
description: invalid request
requestBody:
content:
application/json:
schema:
type: object
properties:
name:
type: string
latitude:
type: number
longitude:
type: number
radius:
type: number
required:
- name
- latitude
- longitude
- radius
examples:
'0':
summary: Creates an area
value:
name: Home
latitude: 40.7128
longitude: -74.006
radius: 100
get:
summary: Retrieves all areas
tags:
- Areas
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
responses:
'200':
description: areas found
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
latitude:
type: number
longitude:
type: number
radius:
type: number
required:
- id
- name
- latitude
- longitude
- radius
"/api/v1/overland/batches":
post:
summary: Creates a batch of points

2
vendor/javascript/leaflet-draw.js vendored Normal file

File diff suppressed because one or more lines are too long