From 605ceee820494ea53eecbccc85ccc19018e8828c Mon Sep 17 00:00:00 2001
From: Eugene Burmakin
Date: Sun, 18 May 2025 00:15:25 +0200
Subject: [PATCH] Add modal to show countries and cities visited in a year
---
CHANGELOG.md | 4 +-
app/helpers/application_helper.rb | 30 ++-
app/helpers/country_flag_helper.rb | 235 ++++++++++++++++++++
app/models/concerns/distanceable.rb | 18 +-
app/models/concerns/nearable.rb | 12 +-
app/services/areas/visits/create.rb | 4 +-
app/services/stats/calculate_month.rb | 10 +-
app/views/imports/index.html.erb | 12 +-
app/views/stats/index.html.erb | 36 ++-
config/initializers/01_constants.rb | 2 +-
spec/services/stats/calculate_month_spec.rb | 10 +
11 files changed, 347 insertions(+), 26 deletions(-)
create mode 100644 app/helpers/country_flag_helper.rb
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a549e1c..35610bf7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,10 +37,11 @@ Also, after updating to this version, Dawarich will start a huge background job
## Added
- Map page now has a button to go to the previous and next day. #296 #631 #904
+- Clicking on number of countries and cities in stats cards now opens a modal with a list of countries and cities visited in that year.
## Changed
-- Reverse geocoding is now working as on-demand job instead of storing the result in the database.
+- Reverse geocoding is now working as on-demand job instead of storing the result in the database. #619
- Stats cards now show the last update time. #733
- Visit card now shows buttons to confirm or decline a visit only if it's not confirmed or declined yet.
- Distance unit is now being stored in the user settings. You can choose between kilometers and miles, default is kilometers. The setting is accessible in the user settings -> Maps -> Distance Unit. You might want to recalculate your stats after changing the unit.
@@ -53,6 +54,7 @@ Also, after updating to this version, Dawarich will start a huge background job
- `rake points:migrate_to_lonlat` should work properly now. #1083 #1161
- PostGIS extension is now being enabled only if it's not already enabled. #1186
- Fixed a bug where visits were returning into Suggested state after being confirmed or declined. #848
+- If no points are found for a month during stats calculation, stats are now being deleted instead of being left empty. #1066 #406
## Removed
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 94c4a8a7..760509a6 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -40,7 +40,35 @@ module ApplicationHelper
data[:cities].flatten!.uniq!
data[:countries].flatten!.uniq!
- "#{data[:countries].count} countries, #{data[:cities].count} cities"
+ # Group cities by country
+ grouped_by_country = {}
+ stats.select { _1.year == year }.each do |stat|
+ stat.toponyms.flatten.each do |toponym|
+ country = toponym['country']
+ next unless country.present?
+
+ grouped_by_country[country] ||= []
+
+ if toponym['cities'].present?
+ toponym['cities'].each do |city_data|
+ city = city_data['city']
+ grouped_by_country[country] << city if city.present?
+ end
+ end
+ end
+ end
+
+ # Deduplicate cities for each country
+ grouped_by_country.transform_values!(&:uniq)
+
+ # Return data for the template to use
+ {
+ countries_count: data[:countries].count,
+ cities_count: data[:cities].count,
+ grouped_by_country: grouped_by_country.transform_values(&:sort).sort.to_h,
+ year: year,
+ modal_id: "countries_cities_modal_#{year}"
+ }
end
def countries_and_cities_stat_for_month(stat)
diff --git a/app/helpers/country_flag_helper.rb b/app/helpers/country_flag_helper.rb
new file mode 100644
index 00000000..d5e4f5b5
--- /dev/null
+++ b/app/helpers/country_flag_helper.rb
@@ -0,0 +1,235 @@
+# frozen_string_literal: true
+
+module CountryFlagHelper
+ def country_flag(country_name)
+ country_code = country_to_code(country_name)
+ return "" unless country_code
+
+ # Convert country code to regional indicator symbols (flag emoji)
+ country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
+ end
+
+
+ private
+
+ def country_to_code(country_name)
+ # Mapping of country names to ISO 3166-1 alpha-2 codes
+ # This is a basic mapping - you might want to expand it based on your data
+ mapping = {
+ "Afghanistan" => "AF",
+ "Albania" => "AL",
+ "Algeria" => "DZ",
+ "Andorra" => "AD",
+ "Angola" => "AO",
+ "Antigua and Barbuda" => "AG",
+ "Argentina" => "AR",
+ "Armenia" => "AM",
+ "Australia" => "AU",
+ "Austria" => "AT",
+ "Azerbaijan" => "AZ",
+ "Bahamas" => "BS",
+ "Bahrain" => "BH",
+ "Bangladesh" => "BD",
+ "Barbados" => "BB",
+ "Belarus" => "BY",
+ "Belgium" => "BE",
+ "Belize" => "BZ",
+ "Benin" => "BJ",
+ "Bhutan" => "BT",
+ "Bolivia" => "BO",
+ "Bosnia and Herzegovina" => "BA",
+ "Botswana" => "BW",
+ "Brazil" => "BR",
+ "Brunei" => "BN",
+ "Bulgaria" => "BG",
+ "Burkina Faso" => "BF",
+ "Burundi" => "BI",
+ "Cabo Verde" => "CV",
+ "Cambodia" => "KH",
+ "Cameroon" => "CM",
+ "Canada" => "CA",
+ "Central African Republic" => "CF",
+ "Chad" => "TD",
+ "Chile" => "CL",
+ "China" => "CN",
+ "Colombia" => "CO",
+ "Comoros" => "KM",
+ "Congo (Brazzaville)" => "CG",
+ "Congo (Kinshasa)" => "CD",
+ "Costa Rica" => "CR",
+ "Croatia" => "HR",
+ "Cuba" => "CU",
+ "Cyprus" => "CY",
+ "Czechia" => "CZ",
+ "Czech Republic" => "CZ",
+ "Denmark" => "DK",
+ "Djibouti" => "DJ",
+ "Dominica" => "DM",
+ "Dominican Republic" => "DO",
+ "Ecuador" => "EC",
+ "Egypt" => "EG",
+ "El Salvador" => "SV",
+ "Equatorial Guinea" => "GQ",
+ "Eritrea" => "ER",
+ "Estonia" => "EE",
+ "Eswatini" => "SZ",
+ "Swaziland" => "SZ",
+ "Ethiopia" => "ET",
+ "Fiji" => "FJ",
+ "Finland" => "FI",
+ "France" => "FR",
+ "Gabon" => "GA",
+ "Gambia" => "GM",
+ "Georgia" => "GE",
+ "Germany" => "DE",
+ "Ghana" => "GH",
+ "Greece" => "GR",
+ "Grenada" => "GD",
+ "Guatemala" => "GT",
+ "Guinea" => "GN",
+ "Guinea-Bissau" => "GW",
+ "Guyana" => "GY",
+ "Haiti" => "HT",
+ "Honduras" => "HN",
+ "Hungary" => "HU",
+ "Iceland" => "IS",
+ "India" => "IN",
+ "Indonesia" => "ID",
+ "Iran" => "IR",
+ "Iraq" => "IQ",
+ "Ireland" => "IE",
+ "Israel" => "IL",
+ "Italy" => "IT",
+ "Jamaica" => "JM",
+ "Japan" => "JP",
+ "Jordan" => "JO",
+ "Kazakhstan" => "KZ",
+ "Kenya" => "KE",
+ "Kiribati" => "KI",
+ "Kuwait" => "KW",
+ "Kyrgyzstan" => "KG",
+ "Laos" => "LA",
+ "Latvia" => "LV",
+ "Lebanon" => "LB",
+ "Lesotho" => "LS",
+ "Liberia" => "LR",
+ "Libya" => "LY",
+ "Liechtenstein" => "LI",
+ "Lithuania" => "LT",
+ "Luxembourg" => "LU",
+ "Madagascar" => "MG",
+ "Malawi" => "MW",
+ "Malaysia" => "MY",
+ "Maldives" => "MV",
+ "Mali" => "ML",
+ "Malta" => "MT",
+ "Marshall Islands" => "MH",
+ "Mauritania" => "MR",
+ "Mauritius" => "MU",
+ "Mexico" => "MX",
+ "Micronesia" => "FM",
+ "Moldova" => "MD",
+ "Monaco" => "MC",
+ "Mongolia" => "MN",
+ "Montenegro" => "ME",
+ "Morocco" => "MA",
+ "Mozambique" => "MZ",
+ "Myanmar" => "MM",
+ "Burma" => "MM",
+ "Namibia" => "NA",
+ "Nauru" => "NR",
+ "Nepal" => "NP",
+ "Netherlands" => "NL",
+ "New Zealand" => "NZ",
+ "Nicaragua" => "NI",
+ "Niger" => "NE",
+ "Nigeria" => "NG",
+ "North Korea" => "KP",
+ "North Macedonia" => "MK",
+ "Norway" => "NO",
+ "Oman" => "OM",
+ "Pakistan" => "PK",
+ "Palau" => "PW",
+ "Palestine" => "PS",
+ "Panama" => "PA",
+ "Papua New Guinea" => "PG",
+ "Paraguay" => "PY",
+ "Peru" => "PE",
+ "Philippines" => "PH",
+ "Poland" => "PL",
+ "Portugal" => "PT",
+ "Qatar" => "QA",
+ "Romania" => "RO",
+ "Russia" => "RU",
+ "Russian Federation" => "RU",
+ "Rwanda" => "RW",
+ "Saint Kitts and Nevis" => "KN",
+ "Saint Lucia" => "LC",
+ "Saint Vincent and the Grenadines" => "VC",
+ "Samoa" => "WS",
+ "San Marino" => "SM",
+ "Sao Tome and Principe" => "ST",
+ "Saudi Arabia" => "SA",
+ "Senegal" => "SN",
+ "Serbia" => "RS",
+ "Seychelles" => "SC",
+ "Sierra Leone" => "SL",
+ "Singapore" => "SG",
+ "Slovakia" => "SK",
+ "Slovenia" => "SI",
+ "Solomon Islands" => "SB",
+ "Somalia" => "SO",
+ "South Africa" => "ZA",
+ "South Korea" => "KR",
+ "South Sudan" => "SS",
+ "Spain" => "ES",
+ "Sri Lanka" => "LK",
+ "Sudan" => "SD",
+ "Suriname" => "SR",
+ "Sweden" => "SE",
+ "Switzerland" => "CH",
+ "Syria" => "SY",
+ "Taiwan" => "TW",
+ "Tajikistan" => "TJ",
+ "Tanzania" => "TZ",
+ "Thailand" => "TH",
+ "Timor-Leste" => "TL",
+ "Togo" => "TG",
+ "Tonga" => "TO",
+ "Trinidad and Tobago" => "TT",
+ "Tunisia" => "TN",
+ "Turkey" => "TR",
+ "Turkmenistan" => "TM",
+ "Tuvalu" => "TV",
+ "Uganda" => "UG",
+ "Ukraine" => "UA",
+ "United Arab Emirates" => "AE",
+ "United Kingdom" => "GB",
+ "UK" => "GB",
+ "Great Britain" => "GB",
+ "United States" => "US",
+ "USA" => "US",
+ "Uruguay" => "UY",
+ "Uzbekistan" => "UZ",
+ "Vanuatu" => "VU",
+ "Vatican City" => "VA",
+ "Venezuela" => "VE",
+ "Vietnam" => "VN",
+ "Yemen" => "YE",
+ "Zambia" => "ZM",
+ "Zimbabwe" => "ZW"
+ }
+
+
+ # Try direct match first
+ return mapping[country_name] if mapping[country_name]
+
+ # Try case-insensitive match or partial match
+ mapping.each do |name, code|
+ return code if country_name.downcase == name.downcase
+ return code if country_name.downcase.include?(name.downcase) || name.downcase.include?(country_name.downcase)
+ end
+
+ nil
+ end
+end
diff --git a/app/models/concerns/distanceable.rb b/app/models/concerns/distanceable.rb
index a9aad852..7ddc190d 100644
--- a/app/models/concerns/distanceable.rb
+++ b/app/models/concerns/distanceable.rb
@@ -16,8 +16,8 @@ module Distanceable
private
def calculate_distance_for_relation(unit)
- unless DISTANCE_UNITS.key?(unit.to_sym)
- raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
+ unless ::DISTANCE_UNITS.key?(unit.to_sym)
+ raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
distance_in_meters = connection.select_value(<<-SQL.squish)
@@ -40,12 +40,12 @@ module Distanceable
WHERE prev_lonlat IS NOT NULL
SQL
- distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
+ distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
end
def calculate_distance_for_array(points, unit = :km)
- unless DISTANCE_UNITS.key?(unit.to_sym)
- raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
+ unless ::DISTANCE_UNITS.key?(unit.to_sym)
+ raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
return 0 if points.length < 2
@@ -58,13 +58,13 @@ module Distanceable
)
end
- total_meters.to_f / DISTANCE_UNITS[unit.to_sym]
+ total_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
end
end
def distance_to(other_point, unit = :km)
- unless DISTANCE_UNITS.key?(unit.to_sym)
- raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
+ unless ::DISTANCE_UNITS.key?(unit.to_sym)
+ raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
# Extract coordinates based on what type other_point is
@@ -80,7 +80,7 @@ module Distanceable
SQL
# Convert to requested unit
- distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
+ distance_in_meters.to_f / ::DISTANCE_UNITS[unit.to_sym]
end
private
diff --git a/app/models/concerns/nearable.rb b/app/models/concerns/nearable.rb
index b217ac12..66ff990a 100644
--- a/app/models/concerns/nearable.rb
+++ b/app/models/concerns/nearable.rb
@@ -11,12 +11,12 @@ module Nearable
def near(*args)
latitude, longitude, radius, unit = extract_coordinates_and_options(*args)
- unless DISTANCE_UNITS.key?(unit.to_sym)
- raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
+ unless ::DISTANCE_UNITS.key?(unit.to_sym)
+ raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
# Convert radius to meters for ST_DWithin
- radius_in_meters = radius * DISTANCE_UNITS[unit.to_sym]
+ radius_in_meters = radius * ::DISTANCE_UNITS[unit.to_sym]
# Create a point from the given coordinates
point = "SRID=4326;POINT(#{longitude} #{latitude})"
@@ -33,12 +33,12 @@ module Nearable
def with_distance(*args)
latitude, longitude, unit = extract_coordinates_and_options(*args)
- unless DISTANCE_UNITS.key?(unit.to_sym)
- raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
+ unless ::DISTANCE_UNITS.key?(unit.to_sym)
+ raise ArgumentError, "Invalid unit. Supported units are: #{::DISTANCE_UNITS.keys.join(', ')}"
end
point = "SRID=4326;POINT(#{longitude} #{latitude})"
- conversion_factor = 1.0 / DISTANCE_UNITS[unit.to_sym]
+ conversion_factor = 1.0 / ::DISTANCE_UNITS[unit.to_sym]
select(<<-SQL.squish)
#{table_name}.*,
diff --git a/app/services/areas/visits/create.rb b/app/services/areas/visits/create.rb
index 76eb2575..6c5faf63 100644
--- a/app/services/areas/visits/create.rb
+++ b/app/services/areas/visits/create.rb
@@ -32,9 +32,9 @@ class Areas::Visits::Create
def area_points(area)
area_radius =
if user.safe_settings.distance_unit == :km
- area.radius / DISTANCE_UNITS[:km]
+ area.radius / ::DISTANCE_UNITS[:km]
else
- area.radius / DISTANCE_UNITS[user.safe_settings.distance_unit.to_sym]
+ area.radius / ::DISTANCE_UNITS[user.safe_settings.distance_unit.to_sym]
end
points = Point.where(user_id: user.id)
diff --git a/app/services/stats/calculate_month.rb b/app/services/stats/calculate_month.rb
index b303d39f..e9d6d64d 100644
--- a/app/services/stats/calculate_month.rb
+++ b/app/services/stats/calculate_month.rb
@@ -8,7 +8,11 @@ class Stats::CalculateMonth
end
def call
- return if points.empty?
+ if points.empty?
+ destroy_month_stats(year, month)
+
+ return
+ end
update_month_stats(year, month)
rescue StandardError => e
@@ -66,4 +70,8 @@ class Stats::CalculateMonth
content: "#{error.message}, stacktrace: #{error.backtrace.join("\n")}"
).call
end
+
+ def destroy_month_stats(year, month)
+ Stat.where(year:, month:, user:).destroy_all
+ end
end
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb
index 0d2f480e..3fde0ef3 100644
--- a/app/views/imports/index.html.erb
+++ b/app/views/imports/index.html.erb
@@ -41,7 +41,9 @@
| Name |
Imported points |
- Reverse geocoded points |
+ <% if DawarichSettings.store_geodata? %>
+ Reverse geocoded points |
+ <% end %>
Created at |
@@ -65,9 +67,11 @@
<%= number_with_delimiter import.processed %>
|
-
- <%= number_with_delimiter import.reverse_geocoded_points_count %>
- |
+ <% if DawarichSettings.store_geodata? %>
+
+ <%= number_with_delimiter import.reverse_geocoded_points_count %>
+ |
+ <% end %>
<%= human_datetime(import.created_at) %> |
<% end %>
diff --git a/app/views/stats/index.html.erb b/app/views/stats/index.html.erb
index 4eea46ed..bac6e0bd 100644
--- a/app/views/stats/index.html.erb
+++ b/app/views/stats/index.html.erb
@@ -44,7 +44,41 @@
<% if DawarichSettings.reverse_geocoding_enabled? %>
- <%= countries_and_cities_stat_for_year(year, stats) %>
+ <% location_data = countries_and_cities_stat_for_year(year, stats) %>
+ <%= link_to "#{location_data[:countries_count]} countries, #{location_data[:cities_count]} cities",
+ "##{location_data[:modal_id]}",
+ class: "link link-primary",
+ onclick: "document.getElementById('#{location_data[:modal_id]}').checked = true" %>
+
+
+
+
+
+
+
Countries and Cities visited in <%= location_data[:year] %>
+
+ <% location_data[:grouped_by_country].each do |country, cities| %>
+
+
+ <%= country_flag(country) %>
+ <%= country %>
+
+ <% if cities.any? %>
+
+ <% cities.each do |city| %>
+
<%= city %>
+ <% end %>
+
+ <% else %>
+
No specific cities recorded
+ <% end %>
+
+ <% end %>
+
+
+
+
+
<% end %>
<%= column_chart(
diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb
index 30584e9d..7f51ffca 100644
--- a/config/initializers/01_constants.rb
+++ b/config/initializers/01_constants.rb
@@ -4,7 +4,7 @@ SELF_HOSTED = ENV.fetch('SELF_HOSTED', 'true') == 'true'
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
-DISTANCE_UNITS = {
+::DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
diff --git a/spec/services/stats/calculate_month_spec.rb b/spec/services/stats/calculate_month_spec.rb
index 7f24adc3..83069d08 100644
--- a/spec/services/stats/calculate_month_spec.rb
+++ b/spec/services/stats/calculate_month_spec.rb
@@ -14,6 +14,16 @@ RSpec.describe Stats::CalculateMonth do
it 'does not create stats' do
expect { calculate_stats }.not_to(change { Stat.count })
end
+
+ context 'when stats already exist for the month' do
+ before do
+ create(:stat, user: user, year: year, month: month)
+ end
+
+ it 'deletes existing stats for that month' do
+ expect { calculate_stats }.to change { Stat.count }.by(-1)
+ end
+ end
end
context 'when there are points' do