mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Add visits page
This commit is contained in:
parent
ab700c8f25
commit
ffe0334ebc
30 changed files with 637 additions and 51 deletions
|
|
@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
### 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.
|
||||
- [ ] Glue two consecutive visits if there are no points between them
|
||||
- [ ] Group visits by day and paginate them
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
3
Gemfile
3
Gemfile
|
|
@ -9,7 +9,9 @@ gem 'chartkick'
|
|||
gem 'data_migrate'
|
||||
gem 'devise'
|
||||
gem 'geocoder'
|
||||
gem 'groupdate'
|
||||
gem 'importmap-rails'
|
||||
gem 'kaminari'
|
||||
gem 'lograge'
|
||||
gem 'oj'
|
||||
gem 'pg'
|
||||
|
|
@ -26,7 +28,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]
|
||||
|
|
|
|||
18
Gemfile.lock
18
Gemfile.lock
|
|
@ -137,6 +137,8 @@ GEM
|
|||
csv (>= 3.0.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
groupdate (6.4.0)
|
||||
activesupport (>= 6.1)
|
||||
hashdiff (1.1.0)
|
||||
i18n (1.14.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
|
|
@ -151,6 +153,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 +403,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
|
||||
|
|
@ -412,7 +425,9 @@ DEPENDENCIES
|
|||
ffaker
|
||||
foreman
|
||||
geocoder
|
||||
groupdate
|
||||
importmap-rails
|
||||
kaminari
|
||||
lograge
|
||||
oj
|
||||
pg
|
||||
|
|
@ -439,7 +454,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
app/controllers/visits_controller.rb
Normal file
43
app/controllers/visits_controller.rb
Normal 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
|
||||
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
|
||||
|
|
@ -4,4 +4,8 @@ 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
|
||||
|
|
@ -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
|
||||
67
app/services/visits/areas/calculate.rb
Normal file
67
app/services/visits/areas/calculate.rb
Normal 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
|
||||
129
app/services/visits/group.rb
Normal file
129
app/services/visits/group.rb
Normal 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
|
||||
|
|
@ -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', 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', 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>
|
||||
|
|
|
|||
52
app/views/visits/index.html.erb
Normal file
52
app/views/visits/index.html.erb
Normal 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>
|
||||
|
|
@ -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 show edit update destroy]
|
||||
resources :exports, only: %i[index create destroy]
|
||||
resources :points, only: %i[index] do
|
||||
collection 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
|
||||
|
|
@ -5,6 +5,11 @@ class CreateVisits < ActiveRecord::Migration[7.1]
|
|||
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
|
||||
|
|
|
|||
5
db/schema.rb
generated
5
db/schema.rb
generated
|
|
@ -168,6 +168,11 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
|
|||
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"
|
||||
|
|
|
|||
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
|
||||
135
spec/requests/visits_spec.rb
Normal file
135
spec/requests/visits_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue