From 5cde596884b6d8dfba72634b640c15bd3cebd1b7 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 11 Dec 2024 17:14:26 +0100 Subject: [PATCH 01/11] Rework countries and cities service --- app/jobs/stats/calculating_job.rb | 7 ++- app/services/countries_and_cities.rb | 62 ++++++++----------- app/views/shared/_right_sidebar.html.erb | 3 - ...11170110_add_index_to_points_timestamp.rb} | 0 4 files changed, 31 insertions(+), 41 deletions(-) rename db/migrate/{[timestamp]_add_index_to_points_timestamp.rb => 20241211170110_add_index_to_points_timestamp.rb} (100%) diff --git a/app/jobs/stats/calculating_job.rb b/app/jobs/stats/calculating_job.rb index 26f4756e..f7a5bc73 100644 --- a/app/jobs/stats/calculating_job.rb +++ b/app/jobs/stats/calculating_job.rb @@ -13,11 +13,14 @@ class Stats::CalculatingJob < ApplicationJob private - def create_stats_updated_notification(user_id) + def create_stats_updated_notification(user_id, year, month) user = User.find(user_id) Notifications::Create.new( - user:, kind: :info, title: 'Stats updated', content: 'Stats updated' + user:, + kind: :info, + title: "Stats updated: #{year}-#{month}", + content: "Stats updated for #{year}-#{month}" ).call end diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index 026484d1..efb36511 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -1,56 +1,46 @@ # frozen_string_literal: true class CountriesAndCities + CityStats = Struct.new(:points, :last_timestamp, :stayed_for, keyword_init: true) + CountryData = Struct.new(:country, :cities, keyword_init: true) + CityData = Struct.new(:city, :points, :timestamp, :stayed_for, keyword_init: true) + def initialize(points) @points = points end def call - grouped_records = group_points - mapped_with_cities = map_with_cities(grouped_records) - filtered_cities = filter_cities(mapped_with_cities) - normalize_result(filtered_cities) + points + .reject { |point| point.country.nil? || point.city.nil? } + .group_by(&:country) + .transform_values { |country_points| process_country_points(country_points) } + .map { |country, cities| CountryData.new(country: country, cities: cities) } end private attr_reader :points - def group_points - points.group_by(&:country) + def process_country_points(country_points) + country_points + .group_by(&:city) + .transform_values do |city_points| + timestamps = city_points.map(&:timestamp) + build_city_data(city_points.first.city, city_points.size, timestamps) + end + .values end - def map_with_cities(grouped_records) - grouped_records.transform_values do |grouped_points| - grouped_points - .pluck(:city, :timestamp) # Extract city and timestamp - .delete_if { _1.first.nil? } # Remove records without city - .group_by { |city, _| city } # Group by city - .transform_values do |cities| - { - points: cities.count, - last_timestamp: cities.map(&:last).max, # Get the maximum timestamp - stayed_for: ((cities.map(&:last).max - cities.map(&:last).min).to_i / 60) # Calculate the time stayed in minutes - } - end - end + def build_city_data(city, points_count, timestamps) + CityData.new( + city: city, + points: points_count, + timestamp: timestamps.max, + stayed_for: calculate_duration_in_minutes(timestamps) + ) end - def filter_cities(mapped_with_cities) - # Remove cities where user stayed for less than 1 hour - mapped_with_cities.transform_values do |cities| - cities.reject { |_, data| data[:stayed_for] < MIN_MINUTES_SPENT_IN_CITY } - end - end - - def normalize_result(hash) - hash.map do |country, cities| - { - country:, - cities: cities.map do |city, data| - { city:, points: data[:points], timestamp: data[:last_timestamp], stayed_for: data[:stayed_for] } - end - } - end + def calculate_duration_in_minutes(timestamps) + ((timestamps.max - timestamps.min).to_i / 60) end end diff --git a/app/views/shared/_right_sidebar.html.erb b/app/views/shared/_right_sidebar.html.erb index 797adaeb..a0f6b2d6 100644 --- a/app/views/shared/_right_sidebar.html.erb +++ b/app/views/shared/_right_sidebar.html.erb @@ -31,10 +31,7 @@ <% if REVERSE_GEOCODING_ENABLED && @countries_and_cities&.any? %>
-

Countries and cities

<% @countries_and_cities.each do |country| %> - <% next if country[:cities].empty? %> -

<%= country[:country] %> (<%= country[:cities].count %> cities)

diff --git a/db/migrate/[timestamp]_add_index_to_points_timestamp.rb b/db/migrate/20241211170110_add_index_to_points_timestamp.rb similarity index 100% rename from db/migrate/[timestamp]_add_index_to_points_timestamp.rb rename to db/migrate/20241211170110_add_index_to_points_timestamp.rb From a4db806d2934303f75f3dd87356105c2a7c28746 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 11 Dec 2024 20:34:49 +0100 Subject: [PATCH 02/11] Add togglable panel for months and years navigation --- .../v1/points/tracked_months_controller.rb | 7 + .../api/v1/points/tracked_months_helper.rb | 2 + app/javascript/controllers/maps_controller.js | 278 ++++++++++++++++++ app/models/user.rb | 11 +- app/views/map/index.html.erb | 6 +- config/routes.rb | 4 + .../api/v1/points/tracked_months_spec.rb | 7 + 7 files changed, 306 insertions(+), 9 deletions(-) create mode 100644 app/controllers/api/v1/points/tracked_months_controller.rb create mode 100644 app/helpers/api/v1/points/tracked_months_helper.rb create mode 100644 spec/requests/api/v1/points/tracked_months_spec.rb diff --git a/app/controllers/api/v1/points/tracked_months_controller.rb b/app/controllers/api/v1/points/tracked_months_controller.rb new file mode 100644 index 00000000..cd430879 --- /dev/null +++ b/app/controllers/api/v1/points/tracked_months_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Api::V1::Points::TrackedMonthsController < ApiController + def index + render json: current_api_user.years_tracked + end +end diff --git a/app/helpers/api/v1/points/tracked_months_helper.rb b/app/helpers/api/v1/points/tracked_months_helper.rb new file mode 100644 index 00000000..7be4d70a --- /dev/null +++ b/app/helpers/api/v1/points/tracked_months_helper.rb @@ -0,0 +1,2 @@ +module Api::V1::Points::TrackedMonthsHelper +end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index d0bb046d..61aa970c 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -171,12 +171,37 @@ export default class extends Controller { if (this.liveMapEnabled) { this.setupSubscription(); } + + // Add the toggle panel button + this.addTogglePanelButton(); + + // Check if we should open the panel based on localStorage or URL params + const urlParams = new URLSearchParams(window.location.search); + const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; + const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); + + console.log('Initial state check:', { + isPanelOpen, + hasDateParams, + localStorageValue: localStorage.getItem('mapPanelOpen') + }); + + if (isPanelOpen || hasDateParams) { + console.log('Opening panel because:', { isPanelOpen, hasDateParams }); + this.toggleRightPanel(); + } } disconnect() { if (this.handleDeleteClick) { document.removeEventListener('click', this.handleDeleteClick); } + // Store panel state before disconnecting + if (this.rightPanel) { + const finalState = this.rightPanel._map ? 'true' : 'false'; + console.log('Disconnecting, saving panel state:', finalState); + localStorage.setItem('mapPanelOpen', finalState); + } this.map.remove(); } @@ -902,4 +927,257 @@ export default class extends Controller { this.photoMarkers.addLayer(marker); } + + addTogglePanelButton() { + const TogglePanelControl = L.Control.extend({ + onAdd: (map) => { + const button = L.DomUtil.create('button', 'toggle-panel-button'); + button.innerHTML = '📊'; // You can use any icon or text here + + // Style the button similarly to the settings button + button.style.backgroundColor = 'white'; + button.style.width = '48px'; + button.style.height = '48px'; + button.style.border = 'none'; + button.style.cursor = 'pointer'; + button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + + // Disable map interactions when clicking the button + L.DomEvent.disableClickPropagation(button); + + // Toggle panel on button click + L.DomEvent.on(button, 'click', () => { + this.toggleRightPanel(); + }); + + return button; + } + }); + + // Add the control to the map + this.map.addControl(new TogglePanelControl({ position: 'topright' })); + } + + toggleRightPanel() { + console.log('toggleRightPanel called, current state:', { + hasPanel: !!this.rightPanel, + isPanelOnMap: this.rightPanel?._map, + localStorageValue: localStorage.getItem('mapPanelOpen') + }); + + if (this.rightPanel) { + if (this.rightPanel._map) { + console.log('Removing panel from map'); + this.map.removeControl(this.rightPanel); + localStorage.setItem('mapPanelOpen', 'false'); + } else { + console.log('Adding existing panel to map'); + this.map.addControl(this.rightPanel); + localStorage.setItem('mapPanelOpen', 'true'); + } + console.log('After toggle:', { + isPanelOnMap: this.rightPanel._map, + localStorageValue: localStorage.getItem('mapPanelOpen') + }); + return; + } + + console.log('Creating new panel'); + this.rightPanel = L.control({ position: 'topright' }); + + this.rightPanel.onAdd = () => { + const div = L.DomUtil.create('div', 'leaflet-right-panel'); + const allMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + // Get current date from URL query parameters + const urlParams = new URLSearchParams(window.location.search); + const startDate = urlParams.get('start_at'); + const currentYear = startDate ? new Date(startDate).getFullYear().toString() : null; + const currentMonth = startDate ? allMonths[new Date(startDate).getMonth()] : null; + + // Initially create select with loading state and current year if available + div.innerHTML = ` +
+
+ + +
+ ${allMonths.map(month => ` + + ${month} + + `).join('')} +
+
+
+ `; + + fetch(`/api/v1/points/tracked_months?api_key=${this.apiKey}`) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(yearsData => { + const yearSelect = document.getElementById('year-select'); + + if (!Array.isArray(yearsData) || yearsData.length === 0) { + yearSelect.innerHTML = ''; + return; + } + + // Check if the current year exists in the API response + const currentYearData = yearsData.find(yearData => yearData.year.toString() === currentYear); + + const options = yearsData + .filter(yearData => yearData && yearData.year) + .map(yearData => { + const months = Array.isArray(yearData.months) ? yearData.months : []; + const isCurrentYear = yearData.year.toString() === currentYear; + return ` + + `; + }) + .join(''); + + yearSelect.innerHTML = ` + + ${options} + `; + + const updateMonthLinks = (selectedYear, availableMonths) => { + // Get current date from URL parameters + const urlParams = new URLSearchParams(window.location.search); + const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : null; + const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : null; + + allMonths.forEach((month, index) => { + const monthLink = div.querySelector(`a[data-month-name="${month}"]`); + if (!monthLink) return; + + // Check if this month falls within the selected date range + const isSelected = startDate && endDate && + selectedYear === startDate.getFullYear().toString() && // Only check months for the currently selected year + isMonthInRange(index, startDate, endDate, parseInt(selectedYear)); + + if (availableMonths.includes(month)) { + monthLink.classList.remove('disabled'); + monthLink.style.pointerEvents = 'auto'; + monthLink.style.opacity = '1'; + + // Update the active state based on selection + if (isSelected) { + monthLink.classList.add('btn-active', 'btn-primary'); + } else { + monthLink.classList.remove('btn-active', 'btn-primary'); + } + + const monthNum = (index + 1).toString().padStart(2, '0'); + const startDate = `${selectedYear}-${monthNum}-01T00:00`; + const lastDay = new Date(selectedYear, index + 1, 0).getDate(); + const endDate = `${selectedYear}-${monthNum}-${lastDay}T23:59`; + + const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; + monthLink.setAttribute('href', href); + } else { + monthLink.classList.add('disabled'); + monthLink.classList.remove('btn-active', 'btn-primary'); + monthLink.style.pointerEvents = 'none'; + monthLink.style.opacity = '0.6'; + monthLink.setAttribute('href', '#'); + } + }); + }; + + // Helper function to check if a month falls within a date range + const isMonthInRange = (monthIndex, startDate, endDate, selectedYear) => { + // Create date objects for the first and last day of the month in the selected year + const monthStart = new Date(selectedYear, monthIndex, 1); + const monthEnd = new Date(selectedYear, monthIndex + 1, 0); + + // Check if any part of the month overlaps with the selected date range + return monthStart <= endDate && monthEnd >= startDate; + }; + + yearSelect.addEventListener('change', (event) => { + const selectedOption = event.target.selectedOptions[0]; + const selectedYear = selectedOption.value; + const availableMonths = JSON.parse(selectedOption.dataset.months || '[]'); + console.log('Year changed to:', selectedYear); + updateMonthLinks(selectedYear, availableMonths); + }); + + // If we have a current year, set it and update month links + if (currentYear && currentYearData) { + yearSelect.value = currentYear; + updateMonthLinks(currentYear, currentYearData.months); + } + }) + .catch(error => { + console.error('Error fetching years:', error); + const yearSelect = document.getElementById('year-select'); + yearSelect.innerHTML = ''; + }); + + div.style.backgroundColor = 'white'; + div.style.padding = '10px'; + div.style.border = '1px solid #ccc'; + div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; + div.style.marginRight = '10px'; + div.style.marginTop = '10px'; + div.style.minWidth = '300px'; + div.style.maxHeight = '80vh'; + div.style.overflowY = 'auto'; + + L.DomEvent.disableClickPropagation(div); + + return div; + }; + + // Only add the panel if we should show it + const urlParams = new URLSearchParams(window.location.search); + const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; + const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); + + console.log('Deciding whether to show new panel:', { + isPanelOpen, + hasDateParams, + localStorageValue: localStorage.getItem('mapPanelOpen') + }); + + if (isPanelOpen || hasDateParams) { + console.log('Adding new panel to map'); + this.map.addControl(this.rightPanel); + localStorage.setItem('mapPanelOpen', 'true'); + } else { + console.log('Not adding new panel to map'); + localStorage.setItem('mapPanelOpen', 'false'); + } + + console.log('Final panel state:', { + isPanelOnMap: this.rightPanel._map, + localStorageValue: localStorage.getItem('mapPanelOpen') + }); + } + + chunk(array, size) { + const chunked = []; + for (let i = 0; i < array.length; i += size) { + chunked.push(array.slice(i, i + size)); + } + return chunked; + } } + diff --git a/app/models/user.rb b/app/models/user.rb index a2d595cb..796cf738 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -70,10 +70,13 @@ class User < ApplicationRecord Rails.cache.fetch("dawarich/user_#{id}_years_tracked", expires_in: 1.day) do tracked_points .pluck(:timestamp) - .map { |ts| Time.zone.at(ts).year } - .uniq - .sort - .reverse + .map { |ts| Time.zone.at(ts) } + .group_by(&:year) + .transform_values do |dates| + dates.map { |date| date.strftime('%b') }.uniq.sort + end + .map { |year, months| { year: year, months: months } } + .sort_by { |entry| -entry[:year] } # Sort in descending order end end diff --git a/app/views/map/index.html.erb b/app/views/map/index.html.erb index 75788972..7e36c225 100644 --- a/app/views/map/index.html.erb +++ b/app/views/map/index.html.erb @@ -1,7 +1,7 @@ <% content_for :title, 'Map' %>
-
+
<%= form_with url: map_path(import_id: params[:import_id]), method: :get do |f| %>
@@ -58,10 +58,6 @@
- -
- <%= render 'shared/right_sidebar' %> -
<%= render 'map/settings_modals' %> diff --git a/config/routes.rb b/config/routes.rb index 2c40e93d..8f179cfa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -80,6 +80,10 @@ Rails.application.routes.draw do resources :borders, only: :index end + namespace :points do + get 'tracked_months', to: 'tracked_months#index' + end + resources :photos, only: %i[index] do member do get 'thumbnail', constraints: { id: %r{[^/]+} } diff --git a/spec/requests/api/v1/points/tracked_months_spec.rb b/spec/requests/api/v1/points/tracked_months_spec.rb new file mode 100644 index 00000000..6abcf28b --- /dev/null +++ b/spec/requests/api/v1/points/tracked_months_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Api::V1::Points::TrackedMonths", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end From a1368b2e6818e260503d9a67f4d6e983a4a39039 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 11 Dec 2024 20:41:51 +0100 Subject: [PATCH 03/11] Add link to whole year --- app/javascript/controllers/maps_controller.js | 60 ++++++++++++++++--- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 61aa970c..2d2f57a0 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -992,18 +992,29 @@ export default class extends Controller { // Get current date from URL query parameters const urlParams = new URLSearchParams(window.location.search); const startDate = urlParams.get('start_at'); - const currentYear = startDate ? new Date(startDate).getFullYear().toString() : null; - const currentMonth = startDate ? allMonths[new Date(startDate).getMonth()] : null; + const currentYear = startDate + ? new Date(startDate).getFullYear().toString() + : new Date().getFullYear().toString(); + const currentMonth = startDate + ? allMonths[new Date(startDate).getMonth()] + : allMonths[new Date().getMonth()]; // Initially create select with loading state and current year if available div.innerHTML = `
- +
+ + + Whole year + +
${allMonths.map(month => ` @@ -1060,8 +1071,8 @@ export default class extends Controller { const updateMonthLinks = (selectedYear, availableMonths) => { // Get current date from URL parameters const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : null; - const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : null; + const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : new Date(); + const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : new Date(); allMonths.forEach((month, index) => { const monthLink = div.querySelector(`a[data-month-name="${month}"]`); @@ -1115,6 +1126,14 @@ export default class extends Controller { const selectedOption = event.target.selectedOptions[0]; const selectedYear = selectedOption.value; const availableMonths = JSON.parse(selectedOption.dataset.months || '[]'); + + // Update whole year link with selected year + const wholeYearLink = document.getElementById('whole-year-link'); + const startDate = `${selectedYear}-01-01T00:00`; + const endDate = `${selectedYear}-12-31T23:59`; + const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; + wholeYearLink.setAttribute('href', href); + console.log('Year changed to:', selectedYear); updateMonthLinks(selectedYear, availableMonths); }); @@ -1179,5 +1198,28 @@ export default class extends Controller { } return chunked; } + + getWholeYearLink() { + // First try to get year from URL parameters + const urlParams = new URLSearchParams(window.location.search); + let year; + + if (urlParams.has('start_at')) { + year = new Date(urlParams.get('start_at')).getFullYear(); + } else { + // If no URL params, try to get year from start_at input + const startAtInput = document.querySelector('input#start_at'); + if (startAtInput && startAtInput.value) { + year = new Date(startAtInput.value).getFullYear(); + } else { + // If no input value, use current year + year = new Date().getFullYear(); + } + } + + const startDate = `${year}-01-01T00:00`; + const endDate = `${year}-12-31T23:59`; + return `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; + } } From cab70839b99746bc6d37593348019625b55f11f4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 11 Dec 2024 21:21:24 +0100 Subject: [PATCH 04/11] Color buttons a bit --- app/javascript/controllers/maps_controller.js | 48 +++---------------- ...211170110_add_index_to_points_timestamp.rb | 9 ---- 2 files changed, 7 insertions(+), 50 deletions(-) delete mode 100644 db/migrate/20241211170110_add_index_to_points_timestamp.rb diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 2d2f57a0..d4ed8c3a 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -180,14 +180,7 @@ export default class extends Controller { const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); - console.log('Initial state check:', { - isPanelOpen, - hasDateParams, - localStorageValue: localStorage.getItem('mapPanelOpen') - }); - if (isPanelOpen || hasDateParams) { - console.log('Opening panel because:', { isPanelOpen, hasDateParams }); this.toggleRightPanel(); } } @@ -199,7 +192,7 @@ export default class extends Controller { // Store panel state before disconnecting if (this.rightPanel) { const finalState = this.rightPanel._map ? 'true' : 'false'; - console.log('Disconnecting, saving panel state:', finalState); + localStorage.setItem('mapPanelOpen', finalState); } this.map.remove(); @@ -932,9 +925,8 @@ export default class extends Controller { const TogglePanelControl = L.Control.extend({ onAdd: (map) => { const button = L.DomUtil.create('button', 'toggle-panel-button'); - button.innerHTML = '📊'; // You can use any icon or text here + button.innerHTML = '📅'; - // Style the button similarly to the settings button button.style.backgroundColor = 'white'; button.style.width = '48px'; button.style.height = '48px'; @@ -959,30 +951,18 @@ export default class extends Controller { } toggleRightPanel() { - console.log('toggleRightPanel called, current state:', { - hasPanel: !!this.rightPanel, - isPanelOnMap: this.rightPanel?._map, - localStorageValue: localStorage.getItem('mapPanelOpen') - }); - if (this.rightPanel) { if (this.rightPanel._map) { - console.log('Removing panel from map'); this.map.removeControl(this.rightPanel); localStorage.setItem('mapPanelOpen', 'false'); } else { - console.log('Adding existing panel to map'); this.map.addControl(this.rightPanel); localStorage.setItem('mapPanelOpen', 'true'); } - console.log('After toggle:', { - isPanelOnMap: this.rightPanel._map, - localStorageValue: localStorage.getItem('mapPanelOpen') - }); + return; } - console.log('Creating new panel'); this.rightPanel = L.control({ position: 'topright' }); this.rightPanel.onAdd = () => { @@ -1011,7 +991,8 @@ export default class extends Controller { + class="btn btn-default" + style="color: rgb(116 128 255) !important;"> Whole year
@@ -1019,9 +1000,9 @@ export default class extends Controller {
${allMonths.map(month => ` + style="pointer-events: none; opacity: 0.6; color: rgb(116 128 255) !important;"> ${month} `).join('')} @@ -1134,7 +1115,6 @@ export default class extends Controller { const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; wholeYearLink.setAttribute('href', href); - console.log('Year changed to:', selectedYear); updateMonthLinks(selectedYear, availableMonths); }); @@ -1145,7 +1125,6 @@ export default class extends Controller { } }) .catch(error => { - console.error('Error fetching years:', error); const yearSelect = document.getElementById('year-select'); yearSelect.innerHTML = ''; }); @@ -1170,25 +1149,12 @@ export default class extends Controller { const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); - console.log('Deciding whether to show new panel:', { - isPanelOpen, - hasDateParams, - localStorageValue: localStorage.getItem('mapPanelOpen') - }); - if (isPanelOpen || hasDateParams) { - console.log('Adding new panel to map'); this.map.addControl(this.rightPanel); localStorage.setItem('mapPanelOpen', 'true'); } else { - console.log('Not adding new panel to map'); localStorage.setItem('mapPanelOpen', 'false'); } - - console.log('Final panel state:', { - isPanelOnMap: this.rightPanel._map, - localStorageValue: localStorage.getItem('mapPanelOpen') - }); } chunk(array, size) { diff --git a/db/migrate/20241211170110_add_index_to_points_timestamp.rb b/db/migrate/20241211170110_add_index_to_points_timestamp.rb deleted file mode 100644 index 8e4bc3fa..00000000 --- a/db/migrate/20241211170110_add_index_to_points_timestamp.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddIndexToPointsTimestamp < ActiveRecord::Migration[7.2] - disable_ddl_transaction! - - def change - add_index :points, %i[user_id timestamp], algorithm: :concurrently - end -end From e7c393a7764533f3e759b53b905ac6f95dcf0ca4 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 11 Dec 2024 22:00:33 +0100 Subject: [PATCH 05/11] Show visited cities on map page --- .../v1/countries/visited_cities_controller.rb | 30 +++++++ .../api/v1/points/tracked_months_helper.rb | 2 - app/javascript/controllers/maps_controller.js | 88 ++++++++++++++++++- config/routes.rb | 1 + .../api/v1/countries/visited_cities_spec.rb | 7 ++ 5 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 app/controllers/api/v1/countries/visited_cities_controller.rb delete mode 100644 app/helpers/api/v1/points/tracked_months_helper.rb create mode 100644 spec/requests/api/v1/countries/visited_cities_spec.rb diff --git a/app/controllers/api/v1/countries/visited_cities_controller.rb b/app/controllers/api/v1/countries/visited_cities_controller.rb new file mode 100644 index 00000000..2b79ffd7 --- /dev/null +++ b/app/controllers/api/v1/countries/visited_cities_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::Countries::VisitedCitiesController < ApiController + before_action :validate_params + + def index + start_at = DateTime.parse(params[:start_at]).to_i + end_at = DateTime.parse(params[:end_at]).to_i + + points = current_api_user + .tracked_points + .where(timestamp: start_at..end_at) + + render json: { data: CountriesAndCities.new(points).call } + end + + private + + def validate_params + missing_params = %i[start_at end_at].select { |param| params[param].blank? } + + if missing_params.any? + render json: { + error: "Missing required parameters: #{missing_params.join(', ')}" + }, status: :bad_request and return + end + + params.permit(:start_at, :end_at) + end +end diff --git a/app/helpers/api/v1/points/tracked_months_helper.rb b/app/helpers/api/v1/points/tracked_months_helper.rb deleted file mode 100644 index 7be4d70a..00000000 --- a/app/helpers/api/v1/points/tracked_months_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module Api::V1::Points::TrackedMonthsHelper -end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index d4ed8c3a..9170f534 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -958,8 +958,9 @@ export default class extends Controller { } else { this.map.addControl(this.rightPanel); localStorage.setItem('mapPanelOpen', 'true'); + // Fetch visited cities when panel is opened + this.fetchAndDisplayVisitedCities(); } - return; } @@ -1008,6 +1009,7 @@ export default class extends Controller { `).join('')}
+
`; @@ -1141,6 +1143,24 @@ export default class extends Controller { L.DomEvent.disableClickPropagation(div); + // Add container for visited cities + div.innerHTML += ` +
+

Visited cities

+
+

Loading visited places...

+
+
+ `; + + // Prevent map zoom when scrolling the cities list + const citiesList = div.querySelector('#visited-cities-list'); + L.DomEvent.disableScrollPropagation(citiesList); + + // Fetch visited cities when panel is first created + this.fetchAndDisplayVisitedCities(); + return div; }; @@ -1187,5 +1207,71 @@ export default class extends Controller { const endDate = `${year}-12-31T23:59`; return `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; } + + async fetchAndDisplayVisitedCities() { + const urlParams = new URLSearchParams(window.location.search); + const startAt = urlParams.get('start_at') || new Date().toISOString(); + const endAt = urlParams.get('end_at') || new Date().toISOString(); + + try { + const response = await fetch(`/api/v1/countries/visited_cities?api_key=${this.apiKey}&start_at=${startAt}&end_at=${endAt}`, { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const data = await response.json(); + this.displayVisitedCities(data.data); + } catch (error) { + console.error('Error fetching visited cities:', error); + const container = document.getElementById('visited-cities-list'); + if (container) { + container.innerHTML = '

Error loading visited places

'; + } + } + } + + displayVisitedCities(citiesData) { + const container = document.getElementById('visited-cities-list'); + if (!container) return; + + if (!citiesData || citiesData.length === 0) { + container.innerHTML = '

No places visited during this period

'; + return; + } + + const html = citiesData.map(country => ` +
+

${country.country}

+
    + ${country.cities.map(city => ` +
  • + ${city.city} + + (${new Date(city.timestamp * 1000).toLocaleDateString()}) + +
  • + `).join('')} +
+
+ `).join(''); + + container.innerHTML = html; + } + + formatDuration(seconds) { + const days = Math.floor(seconds / (24 * 60 * 60)); + const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60)); + + if (days > 0) { + return `${days}d ${hours}h`; + } + return `${hours}h`; + } } diff --git a/config/routes.rb b/config/routes.rb index 8f179cfa..ebad44e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -78,6 +78,7 @@ Rails.application.routes.draw do namespace :countries do resources :borders, only: :index + resources :visited_cities, only: :index end namespace :points do diff --git a/spec/requests/api/v1/countries/visited_cities_spec.rb b/spec/requests/api/v1/countries/visited_cities_spec.rb new file mode 100644 index 00000000..88441b3a --- /dev/null +++ b/spec/requests/api/v1/countries/visited_cities_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Api::V1::Countries::VisitedCities", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end From cddbace10eb12932d49201c16a23859dafe6f12d Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Fri, 13 Dec 2024 13:21:04 +0100 Subject: [PATCH 06/11] Cache responses from api endpoints made from the map right panel --- app/javascript/controllers/maps_controller.js | 320 ++++++++++-------- 1 file changed, 176 insertions(+), 144 deletions(-) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 9170f534..203c0d53 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -32,6 +32,8 @@ export default class extends Controller { settingsButtonAdded = false; layerControl = null; + visitedCitiesCache = new Map(); + trackedMonthsCache = null; connect() { console.log("Map controller connected"); @@ -180,8 +182,16 @@ export default class extends Controller { const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); - if (isPanelOpen || hasDateParams) { - this.toggleRightPanel(); + // Always create the panel first + this.toggleRightPanel(); + + // Then hide it if it shouldn't be open + if (!isPanelOpen && !hasDateParams) { + const panel = document.querySelector('.leaflet-right-panel'); + if (panel) { + panel.style.display = 'none'; + localStorage.setItem('mapPanelOpen', 'false'); + } } } @@ -191,8 +201,7 @@ export default class extends Controller { } // Store panel state before disconnecting if (this.rightPanel) { - const finalState = this.rightPanel._map ? 'true' : 'false'; - + const finalState = document.querySelector('.leaflet-right-panel').style.display !== 'none' ? 'true' : 'false'; localStorage.setItem('mapPanelOpen', finalState); } this.map.remove(); @@ -952,16 +961,17 @@ export default class extends Controller { toggleRightPanel() { if (this.rightPanel) { - if (this.rightPanel._map) { - this.map.removeControl(this.rightPanel); - localStorage.setItem('mapPanelOpen', 'false'); - } else { - this.map.addControl(this.rightPanel); - localStorage.setItem('mapPanelOpen', 'true'); - // Fetch visited cities when panel is opened - this.fetchAndDisplayVisitedCities(); + const panel = document.querySelector('.leaflet-right-panel'); + if (panel) { + if (panel.style.display === 'none') { + panel.style.display = 'block'; + localStorage.setItem('mapPanelOpen', 'true'); + } else { + panel.style.display = 'none'; + localStorage.setItem('mapPanelOpen', 'false'); + } + return; } - return; } this.rightPanel = L.control({ position: 'topright' }); @@ -1004,7 +1014,7 @@ export default class extends Controller { class="btn btn-primary disabled ${month === currentMonth ? 'btn-active' : ''}" data-month-name="${month}" style="pointer-events: none; opacity: 0.6; color: rgb(116 128 255) !important;"> - ${month} + `).join('')}
@@ -1013,123 +1023,7 @@ export default class extends Controller { `; - fetch(`/api/v1/points/tracked_months?api_key=${this.apiKey}`) - .then(response => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - return response.json(); - }) - .then(yearsData => { - const yearSelect = document.getElementById('year-select'); - - if (!Array.isArray(yearsData) || yearsData.length === 0) { - yearSelect.innerHTML = ''; - return; - } - - // Check if the current year exists in the API response - const currentYearData = yearsData.find(yearData => yearData.year.toString() === currentYear); - - const options = yearsData - .filter(yearData => yearData && yearData.year) - .map(yearData => { - const months = Array.isArray(yearData.months) ? yearData.months : []; - const isCurrentYear = yearData.year.toString() === currentYear; - return ` - - `; - }) - .join(''); - - yearSelect.innerHTML = ` - - ${options} - `; - - const updateMonthLinks = (selectedYear, availableMonths) => { - // Get current date from URL parameters - const urlParams = new URLSearchParams(window.location.search); - const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : new Date(); - const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : new Date(); - - allMonths.forEach((month, index) => { - const monthLink = div.querySelector(`a[data-month-name="${month}"]`); - if (!monthLink) return; - - // Check if this month falls within the selected date range - const isSelected = startDate && endDate && - selectedYear === startDate.getFullYear().toString() && // Only check months for the currently selected year - isMonthInRange(index, startDate, endDate, parseInt(selectedYear)); - - if (availableMonths.includes(month)) { - monthLink.classList.remove('disabled'); - monthLink.style.pointerEvents = 'auto'; - monthLink.style.opacity = '1'; - - // Update the active state based on selection - if (isSelected) { - monthLink.classList.add('btn-active', 'btn-primary'); - } else { - monthLink.classList.remove('btn-active', 'btn-primary'); - } - - const monthNum = (index + 1).toString().padStart(2, '0'); - const startDate = `${selectedYear}-${monthNum}-01T00:00`; - const lastDay = new Date(selectedYear, index + 1, 0).getDate(); - const endDate = `${selectedYear}-${monthNum}-${lastDay}T23:59`; - - const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; - monthLink.setAttribute('href', href); - } else { - monthLink.classList.add('disabled'); - monthLink.classList.remove('btn-active', 'btn-primary'); - monthLink.style.pointerEvents = 'none'; - monthLink.style.opacity = '0.6'; - monthLink.setAttribute('href', '#'); - } - }); - }; - - // Helper function to check if a month falls within a date range - const isMonthInRange = (monthIndex, startDate, endDate, selectedYear) => { - // Create date objects for the first and last day of the month in the selected year - const monthStart = new Date(selectedYear, monthIndex, 1); - const monthEnd = new Date(selectedYear, monthIndex + 1, 0); - - // Check if any part of the month overlaps with the selected date range - return monthStart <= endDate && monthEnd >= startDate; - }; - - yearSelect.addEventListener('change', (event) => { - const selectedOption = event.target.selectedOptions[0]; - const selectedYear = selectedOption.value; - const availableMonths = JSON.parse(selectedOption.dataset.months || '[]'); - - // Update whole year link with selected year - const wholeYearLink = document.getElementById('whole-year-link'); - const startDate = `${selectedYear}-01-01T00:00`; - const endDate = `${selectedYear}-12-31T23:59`; - const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; - wholeYearLink.setAttribute('href', href); - - updateMonthLinks(selectedYear, availableMonths); - }); - - // If we have a current year, set it and update month links - if (currentYear && currentYearData) { - yearSelect.value = currentYear; - updateMonthLinks(currentYear, currentYearData.months); - } - }) - .catch(error => { - const yearSelect = document.getElementById('year-select'); - yearSelect.innerHTML = ''; - }); + this.fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths); div.style.backgroundColor = 'white'; div.style.padding = '10px'; @@ -1137,7 +1031,7 @@ export default class extends Controller { div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)'; div.style.marginRight = '10px'; div.style.marginTop = '10px'; - div.style.minWidth = '300px'; + div.style.width = '300px'; div.style.maxHeight = '80vh'; div.style.overflowY = 'auto'; @@ -1148,7 +1042,7 @@ export default class extends Controller {

Visited cities

+ style="max-height: 300px; overflow-y: auto; overflow-x: auto; padding-right: 10px;">

Loading visited places...

@@ -1161,19 +1055,144 @@ export default class extends Controller { // Fetch visited cities when panel is first created this.fetchAndDisplayVisitedCities(); + // Set initial display style based on localStorage + const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; + div.style.display = isPanelOpen ? 'block' : 'none'; + return div; }; - // Only add the panel if we should show it - const urlParams = new URLSearchParams(window.location.search); - const isPanelOpen = localStorage.getItem('mapPanelOpen') === 'true'; - const hasDateParams = urlParams.has('start_at') && urlParams.has('end_at'); + this.map.addControl(this.rightPanel); + } - if (isPanelOpen || hasDateParams) { - this.map.addControl(this.rightPanel); - localStorage.setItem('mapPanelOpen', 'true'); - } else { - localStorage.setItem('mapPanelOpen', 'false'); + async fetchAndDisplayTrackedMonths(div, currentYear, currentMonth, allMonths) { + try { + let yearsData; + + // Check cache first + if (this.trackedMonthsCache) { + yearsData = this.trackedMonthsCache; + } else { + const response = await fetch(`/api/v1/points/tracked_months?api_key=${this.apiKey}`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + yearsData = await response.json(); + // Store in cache + this.trackedMonthsCache = yearsData; + } + + const yearSelect = document.getElementById('year-select'); + + if (!Array.isArray(yearsData) || yearsData.length === 0) { + yearSelect.innerHTML = ''; + return; + } + + // Check if the current year exists in the API response + const currentYearData = yearsData.find(yearData => yearData.year.toString() === currentYear); + + const options = yearsData + .filter(yearData => yearData && yearData.year) + .map(yearData => { + const months = Array.isArray(yearData.months) ? yearData.months : []; + const isCurrentYear = yearData.year.toString() === currentYear; + return ` + + `; + }) + .join(''); + + yearSelect.innerHTML = ` + + ${options} + `; + + const updateMonthLinks = (selectedYear, availableMonths) => { + // Get current date from URL parameters + const urlParams = new URLSearchParams(window.location.search); + const startDate = urlParams.get('start_at') ? new Date(urlParams.get('start_at')) : new Date(); + const endDate = urlParams.get('end_at') ? new Date(urlParams.get('end_at')) : new Date(); + + allMonths.forEach((month, index) => { + const monthLink = div.querySelector(`a[data-month-name="${month}"]`); + if (!monthLink) return; + + // Update the content to show the month name instead of loading dots + monthLink.innerHTML = month; + + // Check if this month falls within the selected date range + const isSelected = startDate && endDate && + selectedYear === startDate.getFullYear().toString() && // Only check months for the currently selected year + isMonthInRange(index, startDate, endDate, parseInt(selectedYear)); + + if (availableMonths.includes(month)) { + monthLink.classList.remove('disabled'); + monthLink.style.pointerEvents = 'auto'; + monthLink.style.opacity = '1'; + + // Update the active state based on selection + if (isSelected) { + monthLink.classList.add('btn-active', 'btn-primary'); + } else { + monthLink.classList.remove('btn-active', 'btn-primary'); + } + + const monthNum = (index + 1).toString().padStart(2, '0'); + const startDate = `${selectedYear}-${monthNum}-01T00:00`; + const lastDay = new Date(selectedYear, index + 1, 0).getDate(); + const endDate = `${selectedYear}-${monthNum}-${lastDay}T23:59`; + + const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; + monthLink.setAttribute('href', href); + } else { + monthLink.classList.add('disabled'); + monthLink.classList.remove('btn-active', 'btn-primary'); + monthLink.style.pointerEvents = 'none'; + monthLink.style.opacity = '0.6'; + monthLink.setAttribute('href', '#'); + } + }); + }; + + // Helper function to check if a month falls within a date range + const isMonthInRange = (monthIndex, startDate, endDate, selectedYear) => { + // Create date objects for the first and last day of the month in the selected year + const monthStart = new Date(selectedYear, monthIndex, 1); + const monthEnd = new Date(selectedYear, monthIndex + 1, 0); + + // Check if any part of the month overlaps with the selected date range + return monthStart <= endDate && monthEnd >= startDate; + }; + + yearSelect.addEventListener('change', (event) => { + const selectedOption = event.target.selectedOptions[0]; + const selectedYear = selectedOption.value; + const availableMonths = JSON.parse(selectedOption.dataset.months || '[]'); + + // Update whole year link with selected year + const wholeYearLink = document.getElementById('whole-year-link'); + const startDate = `${selectedYear}-01-01T00:00`; + const endDate = `${selectedYear}-12-31T23:59`; + const href = `map?end_at=${encodeURIComponent(endDate)}&start_at=${encodeURIComponent(startDate)}`; + wholeYearLink.setAttribute('href', href); + + updateMonthLinks(selectedYear, availableMonths); + }); + + // If we have a current year, set it and update month links + if (currentYear && currentYearData) { + yearSelect.value = currentYear; + updateMonthLinks(currentYear, currentYearData.months); + } + } catch (error) { + const yearSelect = document.getElementById('year-select'); + yearSelect.innerHTML = ''; + console.error('Error fetching tracked months:', error); } } @@ -1213,6 +1232,15 @@ export default class extends Controller { const startAt = urlParams.get('start_at') || new Date().toISOString(); const endAt = urlParams.get('end_at') || new Date().toISOString(); + // Create a cache key from the date range + const cacheKey = `${startAt}-${endAt}`; + + // Check if we have cached data for this date range + if (this.visitedCitiesCache.has(cacheKey)) { + this.displayVisitedCities(this.visitedCitiesCache.get(cacheKey)); + return; + } + try { const response = await fetch(`/api/v1/countries/visited_cities?api_key=${this.apiKey}&start_at=${startAt}&end_at=${endAt}`, { headers: { @@ -1226,6 +1254,10 @@ export default class extends Controller { } const data = await response.json(); + + // Cache the results + this.visitedCitiesCache.set(cacheKey, data.data); + this.displayVisitedCities(data.data); } catch (error) { console.error('Error fetching visited cities:', error); @@ -1246,11 +1278,11 @@ export default class extends Controller { } const html = citiesData.map(country => ` -
+

${country.country}

    ${country.cities.map(city => ` -
  • +
  • ${city.city} (${new Date(city.timestamp * 1000).toLocaleDateString()}) From 41dfbfc1f4d3a6412b35a93760317bc6736b4bff Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 16 Dec 2024 14:27:45 +0100 Subject: [PATCH 07/11] Don't close point popup when hovering over a polyline --- CHANGELOG.md | 11 +++++++++++ app/javascript/controllers/maps_controller.js | 2 +- app/javascript/maps/markers.js | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3bb1f3..1118ff41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [Unreleased] + +### Fixed + +- A point popup is no longer closes when hovering over a polyline. #536 + +### Changed + +- Months and years navigation is moved to a map panel on the right side of the map. +- List of visited cities is now being shown in a map panel on the right side of the map. + # 0.19.7 - 2024-12-11 ### Fixed diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 203c0d53..f6ea421c 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -925,7 +925,7 @@ export default class extends Controller { ${photo.type === 'video' ? '🎥 Video' : '📷 Photo'}
`; - marker.bindPopup(popupContent); + marker.bindPopup(popupContent, { autoClose: false }); this.photoMarkers.addLayer(marker); } diff --git a/app/javascript/maps/markers.js b/app/javascript/maps/markers.js index 25760054..d1da358a 100644 --- a/app/javascript/maps/markers.js +++ b/app/javascript/maps/markers.js @@ -9,7 +9,7 @@ export function createMarkersArray(markersData, userSettings) { const popupContent = createPopupContent(marker, userSettings.timezone, userSettings.distanceUnit); let markerColor = marker[5] < 0 ? "orange" : "blue"; - return L.circleMarker([lat, lon], { radius: 4, color: markerColor }).bindPopup(popupContent); + return L.circleMarker([lat, lon], { radius: 4, color: markerColor }).bindPopup(popupContent, { autoClose: false }); }); } } From d9930521c91c83959a0a367d1ffbe606788e028c Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 16 Dec 2024 15:10:46 +0100 Subject: [PATCH 08/11] Refactor code a bit and add some specs --- app/controllers/stats_controller.rb | 6 +- app/jobs/stats/calculating_job.rb | 2 +- app/models/stat.rb | 14 --- app/services/countries_and_cities.rb | 24 +++-- spec/models/stat_spec.rb | 38 -------- spec/models/user_spec.rb | 4 +- spec/requests/stats_spec.rb | 8 +- spec/services/countries_and_cities_spec.rb | 33 ++++--- .../api/v1/countries/visited_cities_spec.rb | 95 +++++++++++++++++++ swagger/v1/swagger.yaml | 78 +++++++++++++++ 10 files changed, 220 insertions(+), 82 deletions(-) create mode 100644 spec/swagger/api/v1/countries/visited_cities_spec.rb diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index 809f1d98..2305a44e 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -17,8 +17,10 @@ class StatsController < ApplicationController def update current_user.years_tracked.each do |year| - (1..12).each do |month| - Stats::CalculatingJob.perform_later(current_user.id, year, month) + year[:months].each do |month| + Stats::CalculatingJob.perform_later( + current_user.id, year[:year], Date::ABBR_MONTHNAMES.index(month) + ) end end diff --git a/app/jobs/stats/calculating_job.rb b/app/jobs/stats/calculating_job.rb index f7a5bc73..02da4d5e 100644 --- a/app/jobs/stats/calculating_job.rb +++ b/app/jobs/stats/calculating_job.rb @@ -6,7 +6,7 @@ class Stats::CalculatingJob < ApplicationJob def perform(user_id, year, month) Stats::CalculateMonth.new(user_id, year, month).call - create_stats_updated_notification(user_id) + create_stats_updated_notification(user_id, year, month) rescue StandardError => e create_stats_update_failed_notification(user_id, e) end diff --git a/app/models/stat.rb b/app/models/stat.rb index 9376c991..6b2d56dd 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -21,20 +21,6 @@ class Stat < ApplicationRecord end end - def self.year_cities_and_countries(year, user) - start_at = DateTime.new(year).beginning_of_year - end_at = DateTime.new(year).end_of_year - - points = user.tracked_points.without_raw_data.where(timestamp: start_at..end_at) - - data = CountriesAndCities.new(points).call - - { - countries: data.map { _1[:country] }.uniq.count, - cities: data.sum { _1[:cities].count } - } - end - def points user.tracked_points .without_raw_data diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index efb36511..b08f2e9a 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class CountriesAndCities - CityStats = Struct.new(:points, :last_timestamp, :stayed_for, keyword_init: true) + MIN_MINUTES_SPENT_IN_CITY = 30 # You can adjust this value as needed + CountryData = Struct.new(:country, :cities, keyword_init: true) CityData = Struct.new(:city, :points, :timestamp, :stayed_for, keyword_init: true) @@ -24,19 +25,28 @@ class CountriesAndCities def process_country_points(country_points) country_points .group_by(&:city) - .transform_values do |city_points| - timestamps = city_points.map(&:timestamp) - build_city_data(city_points.first.city, city_points.size, timestamps) - end + .transform_values { |city_points| create_city_data_if_valid(city_points) } .values + .compact end - def build_city_data(city, points_count, timestamps) + def create_city_data_if_valid(city_points) + timestamps = city_points.pluck(:timestamp) + duration = calculate_duration_in_minutes(timestamps) + city = city_points.first.city + points_count = city_points.size + + build_city_data(city, points_count, timestamps, duration) + end + + def build_city_data(city, points_count, timestamps, duration) + return nil if duration < MIN_MINUTES_SPENT_IN_CITY + CityData.new( city: city, points: points_count, timestamp: timestamps.max, - stayed_for: calculate_duration_in_minutes(timestamps) + stayed_for: duration ) end diff --git a/spec/models/stat_spec.rb b/spec/models/stat_spec.rb index ae65afd2..af8873b6 100644 --- a/spec/models/stat_spec.rb +++ b/spec/models/stat_spec.rb @@ -13,44 +13,6 @@ RSpec.describe Stat, type: :model do let(:year) { 2021 } let(:user) { create(:user) } - describe '.year_cities_and_countries' do - subject { described_class.year_cities_and_countries(year, user) } - - let(:timestamp) { DateTime.new(year, 1, 1, 0, 0, 0) } - - before do - stub_const('MIN_MINUTES_SPENT_IN_CITY', 60) - end - - context 'when there are points' do - let!(:points) do - [ - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp:), - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 10.minutes), - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 20.minutes), - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 30.minutes), - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 40.minutes), - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 50.minutes), - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 60.minutes), - create(:point, user:, city: 'Berlin', country: 'Germany', timestamp: timestamp + 70.minutes), - create(:point, user:, city: 'Brugges', country: 'Belgium', timestamp: timestamp + 80.minutes), - create(:point, user:, city: 'Brugges', country: 'Belgium', timestamp: timestamp + 90.minutes) - ] - end - - it 'returns countries and cities' do - # User spent only 20 minutes in Brugges, so it should not be included - expect(subject).to eq(countries: 2, cities: 1) - end - end - - context 'when there are no points' do - it 'returns countries and cities' do - expect(subject).to eq(countries: 0, cities: 0) - end - end - end - describe '#distance_by_day' do subject { stat.distance_by_day } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 26bd7e27..a1059d0a 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -117,10 +117,8 @@ RSpec.describe User, type: :model do describe '#years_tracked' do let!(:points) { create_list(:point, 3, user:, timestamp: DateTime.new(2024, 1, 1, 5, 0, 0)) } - subject { user.years_tracked } - it 'returns years tracked' do - expect(subject).to eq([2024]) + expect(user.years_tracked).to eq([{ year: 2024, months: ['Jan'] }]) end end end diff --git a/spec/requests/stats_spec.rb b/spec/requests/stats_spec.rb index 40c19823..224cb3b5 100644 --- a/spec/requests/stats_spec.rb +++ b/spec/requests/stats_spec.rb @@ -55,13 +55,13 @@ RSpec.describe '/stats', type: :request do let(:stat) { create(:stat, user:, year: 2024) } it 'enqueues Stats::CalculatingJob for each tracked year and month' do - allow(user).to receive(:years_tracked).and_return([2024]) + allow(user).to receive(:years_tracked).and_return([{ year: 2024, months: %w[Jan Feb] }]) post stats_url - (1..12).each do |month| - expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, month) - end + expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, 1) + expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, 2) + expect(Stats::CalculatingJob).to_not have_been_enqueued.with(user.id, 2024, 3) end end end diff --git a/spec/services/countries_and_cities_spec.rb b/spec/services/countries_and_cities_spec.rb index 99e7664f..636823e5 100644 --- a/spec/services/countries_and_cities_spec.rb +++ b/spec/services/countries_and_cities_spec.rb @@ -36,13 +36,18 @@ RSpec.describe CountriesAndCities do it 'returns countries and cities' do expect(countries_and_cities).to eq( [ - { - cities: [{ city: 'Berlin', points: 8, timestamp: 1609463400, stayed_for: 70 }], - country: 'Germany' - }, - { - cities: [], country: 'Belgium' - } + CountriesAndCities::CountryData.new( + country: 'Germany', + cities: [ + CountriesAndCities::CityData.new( + city: 'Berlin', points: 8, timestamp: 1_609_463_400, stayed_for: 70 + ) + ] + ), + CountriesAndCities::CountryData.new( + country: 'Belgium', + cities: [] + ) ] ) end @@ -62,12 +67,14 @@ RSpec.describe CountriesAndCities do it 'returns countries and cities' do expect(countries_and_cities).to eq( [ - { - cities: [], country: 'Germany' - }, - { - cities: [], country: 'Belgium' - } + CountriesAndCities::CountryData.new( + country: 'Germany', + cities: [] + ), + CountriesAndCities::CountryData.new( + country: 'Belgium', + cities: [] + ) ] ) end diff --git a/spec/swagger/api/v1/countries/visited_cities_spec.rb b/spec/swagger/api/v1/countries/visited_cities_spec.rb new file mode 100644 index 00000000..5e59dcd1 --- /dev/null +++ b/spec/swagger/api/v1/countries/visited_cities_spec.rb @@ -0,0 +1,95 @@ +# spec/swagger/api/v1/countries/visited_cities_controller_spec.rb +require 'swagger_helper' + +RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do + path '/api/v1/countries/visited_cities' do + get 'Get visited cities by date range' do + tags 'Countries' + description 'Returns a list of visited cities and countries based on tracked points within the specified date range' + produces 'application/json' + + parameter name: :api_key, in: :query, type: :string, required: true + parameter name: :start_at, + in: :query, + type: :string, + format: 'date-time', + required: true, + description: 'Start date and time for the range (ISO 8601 format)', + example: '2023-01-01T00:00:00Z' + + parameter name: :end_at, + in: :query, + type: :string, + format: 'date-time', + required: true, + description: 'End date and time for the range (ISO 8601 format)', + example: '2023-12-31T23:59:59Z' + + response '200', 'cities found' do + schema type: :object, + properties: { + data: { + type: :array, + description: 'Array of countries and their visited cities', + items: { + type: :object, + properties: { + country: { + type: :string, + example: 'Germany' + }, + cities: { + type: :array, + items: { + type: :object, + properties: { + city: { + type: :string, + example: 'Berlin' + }, + points: { + type: :integer, + example: 4394, + description: 'Number of points in the city' + }, + timestamp: { + type: :integer, + example: 1_724_868_369, + description: 'Timestamp of the last point in the city in seconds since Unix epoch' + }, + stayed_for: { + type: :integer, + example: 24_490, + description: 'Number of minutes the user stayed in the city' + } + } + } + } + } + } + } + } + + let(:start_at) { '2023-01-01T00:00:00Z' } + let(:end_at) { '2023-12-31T23:59:59Z' } + let(:api_key) { create(:user).api_key } + run_test! + end + + response '400', 'bad request - missing parameters' do + schema type: :object, + properties: { + error: { + type: :string, + example: 'Missing required parameters: start_at, end_at' + } + } + + let(:start_at) { nil } + let(:end_at) { nil } + let(:api_key) { create(:user).api_key } + run_test! + end + end + end +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 9e6a57dd..07235bd6 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -106,6 +106,84 @@ paths: responses: '200': description: area deleted + "/api/v1/countries/visited_cities": + get: + summary: Get visited cities by date range + tags: + - Countries + description: Returns a list of visited cities and countries based on tracked + points within the specified date range + parameters: + - name: api_key + in: query + required: true + schema: + type: string + - name: start_at + in: query + format: date-time + required: true + description: Start date and time for the range (ISO 8601 format) + example: '2023-01-01T00:00:00Z' + schema: + type: string + - name: end_at + in: query + format: date-time + required: true + description: End date and time for the range (ISO 8601 format) + example: '2023-12-31T23:59:59Z' + schema: + type: string + responses: + '200': + description: cities found + content: + application/json: + schema: + type: object + properties: + data: + type: array + description: Array of countries and their visited cities + items: + type: object + properties: + country: + type: string + example: Germany + cities: + type: array + items: + type: object + properties: + city: + type: string + example: Berlin + points: + type: integer + example: 4394 + description: Number of points in the city + timestamp: + type: integer + example: 1724868369 + description: Timestamp of the last point in the city + in seconds since Unix epoch + stayed_for: + type: integer + example: 24490 + description: Number of minutes the user stayed in + the city + '400': + description: bad request - missing parameters + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Missing required parameters: start_at, end_at' "/api/v1/health": get: summary: Retrieves application status From 81dc03f7c970bdeadc2a92d112e2efa6fb1e6b00 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 16 Dec 2024 15:22:34 +0100 Subject: [PATCH 09/11] Add swagger specs for tracked months and visited cities --- .../api/v1/countries/visited_cities_spec.rb | 16 ++++-- .../api/v1/points/tracked_months_spec.rb | 14 ++++-- .../points/tracked_months_controller_spec.rb | 39 +++++++++++++++ swagger/v1/swagger.yaml | 50 +++++++++++++++++++ 4 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 spec/swagger/api/v1/points/tracked_months_controller_spec.rb diff --git a/spec/requests/api/v1/countries/visited_cities_spec.rb b/spec/requests/api/v1/countries/visited_cities_spec.rb index 88441b3a..65dbab07 100644 --- a/spec/requests/api/v1/countries/visited_cities_spec.rb +++ b/spec/requests/api/v1/countries/visited_cities_spec.rb @@ -1,7 +1,17 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe "Api::V1::Countries::VisitedCities", type: :request do - describe "GET /index" do - pending "add some examples (or delete) #{__FILE__}" +RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do + describe 'GET /index' do + let(:user) { create(:user) } + let(:start_at) { '2023-01-01' } + let(:end_at) { '2023-12-31' } + + it 'returns visited cities' do + get "/api/v1/countries/visited_cities?api_key=#{user.api_key}&start_at=#{start_at}&end_at=#{end_at}" + + expect(response).to have_http_status(:ok) + end end end diff --git a/spec/requests/api/v1/points/tracked_months_spec.rb b/spec/requests/api/v1/points/tracked_months_spec.rb index 6abcf28b..e654fc15 100644 --- a/spec/requests/api/v1/points/tracked_months_spec.rb +++ b/spec/requests/api/v1/points/tracked_months_spec.rb @@ -1,7 +1,15 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe "Api::V1::Points::TrackedMonths", type: :request do - describe "GET /index" do - pending "add some examples (or delete) #{__FILE__}" +RSpec.describe 'Api::V1::Points::TrackedMonths', type: :request do + describe 'GET /index' do + let(:user) { create(:user) } + + it 'returns tracked months' do + get "/api/v1/points/tracked_months?api_key=#{user.api_key}" + + expect(response).to have_http_status(:ok) + end end end diff --git a/spec/swagger/api/v1/points/tracked_months_controller_spec.rb b/spec/swagger/api/v1/points/tracked_months_controller_spec.rb new file mode 100644 index 00000000..fec4c633 --- /dev/null +++ b/spec/swagger/api/v1/points/tracked_months_controller_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +describe 'Points Tracked Months API', type: :request do + path '/api/v1/points/tracked_months' do + get 'Returns list of tracked years and months' do + tags 'Points' + produces 'application/json' + parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key' + response '200', 'years and months found' do + schema type: :array, + items: { + type: :object, + properties: { + year: { type: :integer, description: 'Year in YYYY format' }, + months: { + type: :array, + items: { type: :string, description: 'Three-letter month abbreviation' } + } + }, + required: %w[year months] + }, + example: [{ + year: 2024, + months: %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec] + }] + + let(:api_key) { create(:user).api_key } + run_test! + end + + response '401', 'unauthorized' do + let(:api_key) { 'invalid' } + run_test! + end + end + end +end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 07235bd6..2e6e2587 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -538,6 +538,56 @@ paths: - photoprism '404': description: photo not found + "/api/v1/points/tracked_months": + get: + summary: Returns list of tracked years and months + tags: + - Points + parameters: + - name: api_key + in: query + required: true + description: API Key + schema: + type: string + responses: + '200': + description: years and months found + content: + application/json: + schema: + type: array + items: + type: object + properties: + year: + type: integer + description: Year in YYYY format + months: + type: array + items: + type: string + description: Three-letter month abbreviation + required: + - year + - months + example: + - year: 2024 + months: + - Jan + - Feb + - Mar + - Apr + - May + - Jun + - Jul + - Aug + - Sep + - Oct + - Nov + - Dec + '401': + description: unauthorized "/api/v1/points": get: summary: Retrieves all points From 2ee7bb74a367977ee7ea55aed34014bbf3ddefa8 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 16 Dec 2024 15:42:26 +0100 Subject: [PATCH 10/11] Fix some swagger specs and remove unused code --- CHANGELOG.md | 5 ++ .../v1/countries/visited_cities_controller.rb | 12 +--- app/controllers/api_controller.rb | 16 +++++ app/services/countries_and_cities.rb | 4 +- app/views/shared/_right_sidebar.html.erb | 61 ------------------- .../api/v1/countries/visited_cities_spec.rb | 15 ++--- 6 files changed, 32 insertions(+), 81 deletions(-) delete mode 100644 app/views/shared/_right_sidebar.html.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 1118ff41..c07c266a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # [Unreleased] +### Added + +- `GET /api/v1/points/tracked_months` endpoint added to get list of tracked years and months. +- `GET /api/v1/countries/visited_cities` endpoint added to get list of visited cities. + ### Fixed - A point popup is no longer closes when hovering over a polyline. #536 diff --git a/app/controllers/api/v1/countries/visited_cities_controller.rb b/app/controllers/api/v1/countries/visited_cities_controller.rb index 2b79ffd7..85e53f7d 100644 --- a/app/controllers/api/v1/countries/visited_cities_controller.rb +++ b/app/controllers/api/v1/countries/visited_cities_controller.rb @@ -16,15 +16,7 @@ class Api::V1::Countries::VisitedCitiesController < ApiController private - def validate_params - missing_params = %i[start_at end_at].select { |param| params[param].blank? } - - if missing_params.any? - render json: { - error: "Missing required parameters: #{missing_params.join(', ')}" - }, status: :bad_request and return - end - - params.permit(:start_at, :end_at) + def required_params + %i[start_at end_at] end end diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 8e2b43e2..934cdc6b 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -15,4 +15,20 @@ class ApiController < ApplicationController def current_api_user @current_api_user ||= User.find_by(api_key: params[:api_key]) end + + def validate_params + missing_params = required_params.select { |param| params[param].blank? } + + if missing_params.any? + render json: { + error: "Missing required parameters: #{missing_params.join(', ')}" + }, status: :bad_request and return + end + + params.permit(*required_params) + end + + def required_params + [] + end end diff --git a/app/services/countries_and_cities.rb b/app/services/countries_and_cities.rb index b08f2e9a..0785107a 100644 --- a/app/services/countries_and_cities.rb +++ b/app/services/countries_and_cities.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class CountriesAndCities - MIN_MINUTES_SPENT_IN_CITY = 30 # You can adjust this value as needed - CountryData = Struct.new(:country, :cities, keyword_init: true) CityData = Struct.new(:city, :points, :timestamp, :stayed_for, keyword_init: true) @@ -40,7 +38,7 @@ class CountriesAndCities end def build_city_data(city, points_count, timestamps, duration) - return nil if duration < MIN_MINUTES_SPENT_IN_CITY + return nil if duration < ::MIN_MINUTES_SPENT_IN_CITY CityData.new( city: city, diff --git a/app/views/shared/_right_sidebar.html.erb b/app/views/shared/_right_sidebar.html.erb deleted file mode 100644 index a0f6b2d6..00000000 --- a/app/views/shared/_right_sidebar.html.erb +++ /dev/null @@ -1,61 +0,0 @@ -<%= sidebar_distance(@distance) %> <%= sidebar_points(@points) %> - -
- - - <% @years.each do |year| %> -

- <%= year %> -

- -
- <% (1..12).to_a.each_slice(3) do |months| %> - <% months.each do |month_number| %> - <% if past?(year, month_number) && points_exist?(year, month_number, current_user) %> - <%= link_to Date::ABBR_MONTHNAMES[month_number], map_url(timespan(month_number, year).merge(import_id: params[:import_id])), class: 'btn btn-default' %> - <% else %> -
<%= Date::ABBR_MONTHNAMES[month_number] %>
- <% end %> - <% end %> - <% end %> -
- <% end %> -
- -<% if REVERSE_GEOCODING_ENABLED && @countries_and_cities&.any? %> -
- <% @countries_and_cities.each do |country| %> -

- <%= country[:country] %> (<%= country[:cities].count %> cities) -

-
    - <% country[:cities].each do |city| %> -
  • -
    -
    <%= link_to_date(city[:timestamp]) %>
    -
    - - - -
    -
    <%= city[:city] %>
    -
    -
  • - <% end %> -
- <% end %> -<% end %> diff --git a/spec/swagger/api/v1/countries/visited_cities_spec.rb b/spec/swagger/api/v1/countries/visited_cities_spec.rb index 5e59dcd1..5d199e15 100644 --- a/spec/swagger/api/v1/countries/visited_cities_spec.rb +++ b/spec/swagger/api/v1/countries/visited_cities_spec.rb @@ -1,4 +1,5 @@ -# spec/swagger/api/v1/countries/visited_cities_controller_spec.rb +# frozen_string_literal: true + require 'swagger_helper' RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do @@ -14,16 +15,16 @@ RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do type: :string, format: 'date-time', required: true, - description: 'Start date and time for the range (ISO 8601 format)', - example: '2023-01-01T00:00:00Z' + description: 'Start date in YYYY-MM-DD format', + example: '2023-01-01' parameter name: :end_at, in: :query, type: :string, format: 'date-time', required: true, - description: 'End date and time for the range (ISO 8601 format)', - example: '2023-12-31T23:59:59Z' + description: 'End date in YYYY-MM-DD format', + example: '2023-12-31' response '200', 'cities found' do schema type: :object, @@ -70,8 +71,8 @@ RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do } } - let(:start_at) { '2023-01-01T00:00:00Z' } - let(:end_at) { '2023-12-31T23:59:59Z' } + let(:start_at) { '2023-01-01' } + let(:end_at) { '2023-12-31' } let(:api_key) { create(:user).api_key } run_test! end From 9572e5bf6b42c21448f443d99ab62cbce271b9cd Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 16 Dec 2024 15:44:31 +0100 Subject: [PATCH 11/11] Change version --- .app_version | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.app_version b/.app_version index 082b4352..158ef578 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.19.7 +0.19.8 diff --git a/CHANGELOG.md b/CHANGELOG.md index c07c266a..17870c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -# [Unreleased] +# 0.19.8 - 2024-12-16 ### Added