mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-13 10:41:38 -05:00
commit
4da8313b51
61 changed files with 1558 additions and 80 deletions
|
|
@ -1 +1 @@
|
|||
0.9.4
|
||||
0.9.5
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
MAPBOX_ACCESS_TOKEN=MAPBOX_ACCESS_TOKEN
|
||||
87
.github/workflows/ci.yml
vendored
87
.github/workflows/ci.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ruby-3
|
||||
3.2.3
|
||||
|
|
|
|||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
2
Gemfile
2
Gemfile
|
|
@ -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]
|
||||
|
|
|
|||
15
Gemfile.lock
15
Gemfile.lock
|
|
@ -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
|
|
@ -19,3 +19,7 @@
|
|||
text-align: center;
|
||||
line-height: 36px; /* Same as font-size for perfect centering */
|
||||
}
|
||||
|
||||
.timeline-box {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
|
|
|||
46
app/controllers/api/v1/areas_controller.rb
Normal file
46
app/controllers/api/v1/areas_controller.rb
Normal 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
|
||||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
36
app/controllers/visits_controller.rb
Normal file
36
app/controllers/visits_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
app/jobs/area_visits_calculating_job.rb
Normal file
12
app/jobs/area_visits_calculating_job.rb
Normal 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
|
||||
9
app/jobs/area_visits_calculation_scheduling_job.rb
Normal file
9
app/jobs/area_visits_calculation_scheduling_job.rb
Normal 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
8
app/models/area.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
11
app/models/visit.rb
Normal 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
24
app/models/visit_draft.rb
Normal 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
|
||||
83
app/services/areas/visits/create.rb
Normal file
83
app/services/areas/visits/create.rb
Normal 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
127
app/services/visitcalc.rb
Normal 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
|
||||
|
|
@ -5,7 +5,7 @@ class Visits::Calculate
|
|||
@points = points
|
||||
end
|
||||
|
||||
def call
|
||||
def city_visits
|
||||
normalize_result(city_visits)
|
||||
end
|
||||
|
||||
|
|
|
|||
130
app/services/visits/group.rb
Normal file
130
app/services/visits/group.rb
Normal 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
|
||||
|
|
@ -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" %>
|
||||
|
|
|
|||
|
|
@ -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 %>"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
<% end %>
|
||||
|
||||
<div class='text-center my-5'>
|
||||
<%= will_paginate @points %>
|
||||
<%= paginate @points %>
|
||||
</div>
|
||||
|
||||
<div id="points" class="min-w-full">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
56
app/views/visits/index.html.erb
Normal file
56
app/views/visits/index.html.erb
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
17
db/data/20240724141417_add_visit_settings_to_user.rb
Normal file
17
db/data/20240724141417_add_visit_settings_to_user.rb
Normal 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
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreatePoints < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :points do |t|
|
||||
|
|
|
|||
15
db/migrate/20240721165313_create_areas.rb
Normal file
15
db/migrate/20240721165313_create_areas.rb
Normal 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
|
||||
17
db/migrate/20240721183005_create_visits.rb
Normal file
17
db/migrate/20240721183005_create_visits.rb
Normal 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
|
||||
7
db/migrate/20240721183116_add_visit_id_to_points.rb
Normal file
7
db/migrate/20240721183116_add_visit_id_to_points.rb
Normal 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
34
db/schema.rb
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
11
spec/factories/areas.rb
Normal 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
13
spec/factories/visits.rb
Normal 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
|
||||
5
spec/jobs/area_visits_calculating_job_spec.rb
Normal file
5
spec/jobs/area_visits_calculating_job_spec.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AreaVisitsCalculatingJob, type: :job do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
5
spec/jobs/area_visits_calculation_scheduling_job_spec.rb
Normal file
5
spec/jobs/area_visits_calculation_scheduling_job_spec.rb
Normal 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
21
spec/models/area_spec.rb
Normal 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
|
||||
|
|
@ -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
15
spec/models/visit_spec.rb
Normal 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
|
||||
100
spec/requests/api/v1/areas_spec.rb
Normal file
100
spec/requests/api/v1/areas_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
45
spec/requests/visits_spec.rb
Normal file
45
spec/requests/visits_spec.rb
Normal 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
|
||||
109
spec/services/areas/visits/create_spec.rb
Normal file
109
spec/services/areas/visits/create_spec.rb
Normal 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
|
||||
67
spec/swagger/api/v1/areas_controller_spec.rb
Normal file
67
spec/swagger/api/v1/areas_controller_spec.rb
Normal 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
|
||||
|
|
@ -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
2
vendor/javascript/leaflet-draw.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue