Add visits page

This commit is contained in:
Eugene Burmakin 2024-07-24 20:25:16 +02:00
parent ab700c8f25
commit ffe0334ebc
30 changed files with 637 additions and 51 deletions

View file

@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added ### 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 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.
- [ ] Glue two consecutive visits if there are no points between them
- [ ] Group visits by day and paginate them
--- ---

View file

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

View file

@ -137,6 +137,8 @@ GEM
csv (>= 3.0.0) csv (>= 3.0.0)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
groupdate (6.4.0)
activesupport (>= 6.1)
hashdiff (1.1.0) hashdiff (1.1.0)
i18n (1.14.5) i18n (1.14.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@ -151,6 +153,18 @@ GEM
json (2.7.2) json (2.7.2)
json-schema (4.3.0) json-schema (4.3.0)
addressable (>= 2.8) 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) language_server-protocol (3.17.0.3)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
@ -389,7 +403,6 @@ GEM
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
will_paginate (4.0.1)
zeitwerk (2.6.16) zeitwerk (2.6.16)
PLATFORMS PLATFORMS
@ -412,7 +425,9 @@ DEPENDENCIES
ffaker ffaker
foreman foreman
geocoder geocoder
groupdate
importmap-rails importmap-rails
kaminari
lograge lograge
oj oj
pg pg
@ -439,7 +454,6 @@ DEPENDENCIES
turbo-rails turbo-rails
tzinfo-data tzinfo-data
webmock webmock
will_paginate (~> 4.0)
RUBY VERSION RUBY VERSION
ruby 3.2.3p157 ruby 3.2.3p157

File diff suppressed because one or more lines are too long

View file

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

View file

@ -6,7 +6,7 @@ class NotificationsController < ApplicationController
def index def index
@notifications = @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 end
def show def show

View file

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

View file

@ -30,7 +30,8 @@ class SettingsController < ApplicationController
def settings_params def settings_params
params.require(:settings).permit( params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters :meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes
) )
end end
end end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
class VisitsController < ApplicationController
before_action
before_action :set_visit, only: %i[edit update destroy]
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 edit; 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
def destroy
@visit.destroy!
redirect_to visits_url, notice: 'Visit was successfully destroyed.', status: :see_other
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

@ -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

View file

@ -4,4 +4,8 @@ class Visit < ApplicationRecord
belongs_to :area belongs_to :area
belongs_to :user belongs_to :user
has_many :points, dependent: :nullify has_many :points, dependent: :nullify
validates :started_at, :ended_at, :duration, :name, :status, presence: true
enum status: { pending: 0, confirmed: 1, declined: 2 }
end 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

@ -1,40 +0,0 @@
# frozen_string_literal: true
class Visits::Area::Calculate
attr_accessor :point
def initialize(point)
@point = point
end
def call
return unless point.city && point.country
# After a reverse geocoding process done for a point, check if there are any areas in the same country+city.
# If there are, check if the point coordinates are within the area's boundaries.
# If they are, find or create a Visit: Name of Area + point.id (visit has many points and belongs to area, point optionally belong to a visit)
#
areas = Area.where(city: point.city, country: point.country)
end
private
def days
# 1. Getting all the points within the area
points = Point.near([area.latitude, area.longitude], area.radius).order(:timestamp)
# 2. Grouping the points by date
points.group_by { |point| Time.at(point.timestamp).to_date }
end
def visits
# 3. Within each day, group points by hour. If difference between two groups is less than 1 hour, they are considered to be part of the same visit.
days.map do |day, points|
points.group_by { |point| Time.at(point.timestamp).strftime('%Y-%m-%d %H') }
end
# 4. If a visit has more than 1 point, it is considered a visit.
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
class Visits::Areas::Calculate
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 = {}
points_grouped_by_month.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.each do |month, visits|
Rails.logger.info("Month: #{month}, Total visits: #{visits.size}")
visits.each do |time_range, visit_points|
Rails.logger.info("Visit from #{time_range}, Points: #{visit_points.size}")
ActiveRecord::Base.transaction do
visit = Visit.find_or_initialize_by(
area_id: area.id,
user_id: user.id,
started_at: Time.zone.at(visit_points.first.timestamp)
)
visit.update!(
name: "#{area.name}, #{time_range}",
area_id: area.id,
user_id: user.id,
started_at: Time.zone.at(visit_points.first.timestamp),
ended_at: Time.zone.at(visit_points.last.timestamp),
duration: (visit_points.last.timestamp - visit_points.first.timestamp) / 60, # in minutes
status: :pending
)
visit_points.each { _1.update!(visit_id: visit.id) }
end
end
end
end
def area_points(area)
area_radius_in_km = area.radius / 1000.0
Point.where(user_id: user.id)
.near([area.latitude, area.longitude], area_radius_in_km)
.order(timestamp: :asc)
.group_by { |point| Time.zone.at(point.timestamp).strftime('%Y-%m') }
end
end

View file

@ -0,0 +1,129 @@
# 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

@ -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" %> <%= 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>
<div class="mb-4"> <div class="mb-4">
<%= will_paginate @notifications %> <%= paginate @notifications %>
</div> </div>
</div> </div>
<div id="notifications" class="w-full max-w-2xl"> <div id="notifications" class="w-full max-w-2xl">

View file

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

View file

@ -80,6 +80,66 @@
<% end %> <% end %>
<%= f.number_field :fog_of_war_meters, value: current_user.settings['fog_of_war_meters'], class: "input input-bordered" %> <%= f.number_field :fog_of_war_meters, value: current_user.settings['fog_of_war_meters'], class: "input input-bordered" %>
</div> </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"> <div class="form-control my-2">
<%= f.submit "Update", class: "btn btn-primary" %> <%= f.submit "Update", class: "btn btn-primary" %>
</div> </div>

View file

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

View file

@ -38,10 +38,25 @@
<h2 class="text-lg font-semibold mt-5"> <h2 class="text-lg font-semibold mt-5">
<%= country[:country] %> (<%= country[:cities].count %> cities) <%= country[:country] %> (<%= country[:cities].count %> cities)
</h2> </h2>
<ul> <ul class="timeline timeline-vertical">
<% country[:cities].each do |city| %> <% country[:cities].each do |city| %>
<li> <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> </li>
<% end %> <% end %>
</ul> </ul>

View file

@ -0,0 +1,52 @@
<div class="w-full">
<% content_for :title, "Visits" %>
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Visits</h1>
</div>
<%= paginate @visits %>
<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

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

View file

@ -4,3 +4,8 @@ stat_creating_job:
cron: "0 */6 * * *" cron: "0 */6 * * *"
class: "StatCreatingJob" class: "StatCreatingJob"
queue: default 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

@ -5,6 +5,11 @@ class CreateVisits < ActiveRecord::Migration[7.1]
create_table :visits do |t| create_table :visits do |t|
t.references :area, null: false, foreign_key: true t.references :area, null: false, foreign_key: true
t.references :user, 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 t.timestamps
end end

5
db/schema.rb generated
View file

@ -168,6 +168,11 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
create_table "visits", force: :cascade do |t| create_table "visits", force: :cascade do |t|
t.bigint "area_id", null: false t.bigint "area_id", null: false
t.bigint "user_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 "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["area_id"], name: "index_visits_on_area_id" t.index ["area_id"], name: "index_visits_on_area_id"

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

View file

@ -0,0 +1,135 @@
require 'rails_helper'
# This spec was generated by rspec-rails when you ran the scaffold generator.
# It demonstrates how one might use RSpec to test the controller code that
# was generated by Rails when you ran the scaffold generator.
#
# It assumes that the implementation code is generated by the rails scaffold
# generator. If you are using any extension libraries to generate different
# controller code, this generated spec may or may not pass.
#
# It only uses APIs available in rails and/or rspec-rails. There are a number
# of tools you can use to make these specs even more expressive, but we're
# sticking to rails and rspec-rails APIs to keep things simple and stable.
RSpec.describe "/visits", type: :request do
# This should return the minimal set of attributes required to create a valid
# Visit. As you add validations to Visit, be sure to
# adjust the attributes here as well.
let(:valid_attributes) {
skip("Add a hash of attributes valid for your model")
}
let(:invalid_attributes) {
skip("Add a hash of attributes invalid for your model")
}
describe "GET /index" do
it "renders a successful response" do
Visit.create! valid_attributes
get visits_url
expect(response).to be_successful
end
end
describe "GET /show" do
it "renders a successful response" do
visit = Visit.create! valid_attributes
get visit_url(visit)
expect(response).to be_successful
end
end
describe "GET /new" do
it "renders a successful response" do
get new_visit_url
expect(response).to be_successful
end
end
describe "GET /edit" do
it "renders a successful response" do
visit = Visit.create! valid_attributes
get edit_visit_url(visit)
expect(response).to be_successful
end
end
describe "POST /create" do
context "with valid parameters" do
it "creates a new Visit" do
expect {
post visits_url, params: { visit: valid_attributes }
}.to change(Visit, :count).by(1)
end
it "redirects to the created visit" do
post visits_url, params: { visit: valid_attributes }
expect(response).to redirect_to(visit_url(Visit.last))
end
end
context "with invalid parameters" do
it "does not create a new Visit" do
expect {
post visits_url, params: { visit: invalid_attributes }
}.to change(Visit, :count).by(0)
end
it "renders a response with 422 status (i.e. to display the 'new' template)" do
post visits_url, params: { visit: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "PATCH /update" do
context "with valid parameters" do
let(:new_attributes) {
skip("Add a hash of attributes valid for your model")
}
it "updates the requested visit" do
visit = Visit.create! valid_attributes
patch visit_url(visit), params: { visit: new_attributes }
visit.reload
skip("Add assertions for updated state")
end
it "redirects to the visit" do
visit = Visit.create! valid_attributes
patch visit_url(visit), params: { visit: new_attributes }
visit.reload
expect(response).to redirect_to(visit_url(visit))
end
end
context "with invalid parameters" do
it "renders a response with 422 status (i.e. to display the 'edit' template)" do
visit = Visit.create! valid_attributes
patch visit_url(visit), params: { visit: invalid_attributes }
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
describe "DELETE /destroy" do
it "destroys the requested visit" do
visit = Visit.create! valid_attributes
expect {
delete visit_url(visit)
}.to change(Visit, :count).by(-1)
end
it "redirects to the visits list" do
visit = Visit.create! valid_attributes
delete visit_url(visit)
expect(response).to redirect_to(visits_url)
end
end
end