mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
226 lines
6.3 KiB
Ruby
226 lines
6.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module Users
|
|
module Digests
|
|
class CalculateYear
|
|
MINUTES_PER_DAY = 1440
|
|
|
|
def initialize(user_id, year)
|
|
@user = ::User.find(user_id)
|
|
@year = year.to_i
|
|
end
|
|
|
|
def call
|
|
return nil if monthly_stats.empty?
|
|
|
|
digest = Users::Digest.find_or_initialize_by(user: user, year: year, period_type: :yearly)
|
|
|
|
digest.assign_attributes(
|
|
distance: total_distance,
|
|
toponyms: aggregate_toponyms,
|
|
monthly_distances: build_monthly_distances,
|
|
time_spent_by_location: calculate_time_spent,
|
|
first_time_visits: calculate_first_time_visits,
|
|
year_over_year: calculate_yoy_comparison,
|
|
all_time_stats: calculate_all_time_stats
|
|
)
|
|
|
|
digest.save!
|
|
digest
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :user, :year
|
|
|
|
def monthly_stats
|
|
@monthly_stats ||= user.stats.where(year: year).order(:month)
|
|
end
|
|
|
|
def total_distance
|
|
monthly_stats.sum(:distance)
|
|
end
|
|
|
|
def aggregate_toponyms
|
|
country_cities = Hash.new { |h, k| h[k] = Set.new }
|
|
|
|
monthly_stats.each do |stat|
|
|
toponyms = stat.toponyms
|
|
next unless toponyms.is_a?(Array)
|
|
|
|
toponyms.each do |toponym|
|
|
next unless toponym.is_a?(Hash)
|
|
|
|
country = toponym['country']
|
|
next if country.blank?
|
|
|
|
if toponym['cities'].is_a?(Array)
|
|
toponym['cities'].each do |city|
|
|
city_name = city['city'] if city.is_a?(Hash)
|
|
country_cities[country].add(city_name) if city_name.present?
|
|
end
|
|
else
|
|
# Ensure country appears even if no cities
|
|
country_cities[country]
|
|
end
|
|
end
|
|
end
|
|
|
|
country_cities.sort_by { |_country, cities| -cities.size }.map do |country, cities|
|
|
{
|
|
'country' => country,
|
|
'cities' => cities.to_a.sort.map { |city| { 'city' => city } }
|
|
}
|
|
end
|
|
end
|
|
|
|
def build_monthly_distances
|
|
result = {}
|
|
|
|
monthly_stats.each do |stat|
|
|
result[stat.month.to_s] = stat.distance.to_s
|
|
end
|
|
|
|
# Fill in missing months with 0
|
|
(1..12).each do |month|
|
|
result[month.to_s] ||= '0'
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
def calculate_time_spent
|
|
country_minutes = calculate_actual_country_minutes
|
|
|
|
{
|
|
'countries' => format_top_countries(country_minutes),
|
|
'cities' => calculate_city_time_spent,
|
|
'total_country_minutes' => country_minutes.values.sum
|
|
}
|
|
end
|
|
|
|
def format_top_countries(country_minutes)
|
|
country_minutes
|
|
.sort_by { |_, minutes| -minutes }
|
|
.first(10)
|
|
.map { |name, minutes| { 'name' => name, 'minutes' => minutes } }
|
|
end
|
|
|
|
def calculate_actual_country_minutes
|
|
points_by_date = group_points_by_date
|
|
country_minutes = Hash.new(0)
|
|
|
|
points_by_date.each do |_date, day_points|
|
|
countries_on_day = day_points.map(&:country_name).uniq
|
|
|
|
if countries_on_day.size == 1
|
|
# Single country day - assign full day
|
|
country_minutes[countries_on_day.first] += MINUTES_PER_DAY
|
|
else
|
|
# Multi-country day - calculate proportional time
|
|
calculate_proportional_time(day_points, country_minutes)
|
|
end
|
|
end
|
|
|
|
country_minutes
|
|
end
|
|
|
|
def group_points_by_date
|
|
points = fetch_year_points_with_country_ordered
|
|
|
|
points.group_by do |point|
|
|
Time.zone.at(point.timestamp).to_date
|
|
end
|
|
end
|
|
|
|
def calculate_proportional_time(day_points, country_minutes)
|
|
country_spans = Hash.new(0)
|
|
points_by_country = day_points.group_by(&:country_name)
|
|
|
|
points_by_country.each do |country, country_points|
|
|
timestamps = country_points.map(&:timestamp)
|
|
span_seconds = timestamps.max - timestamps.min
|
|
# Minimum 60 seconds (1 min) for single-point countries
|
|
country_spans[country] = [span_seconds, 60].max
|
|
end
|
|
|
|
total_spans = country_spans.values.sum.to_f
|
|
|
|
country_spans.each do |country, span|
|
|
proportional_minutes = (span / total_spans * MINUTES_PER_DAY).round
|
|
country_minutes[country] += proportional_minutes
|
|
end
|
|
end
|
|
|
|
def fetch_year_points_with_country_ordered
|
|
start_of_year = Time.zone.local(year, 1, 1, 0, 0, 0)
|
|
end_of_year = start_of_year.end_of_year
|
|
|
|
user.points
|
|
.without_raw_data
|
|
.where('timestamp >= ? AND timestamp <= ?', start_of_year.to_i, end_of_year.to_i)
|
|
.where.not(country_name: [nil, ''])
|
|
.select(:country_name, :timestamp)
|
|
.order(timestamp: :asc)
|
|
end
|
|
|
|
def calculate_city_time_spent
|
|
city_time = aggregate_city_time_from_monthly_stats
|
|
|
|
city_time
|
|
.sort_by { |_, minutes| -minutes }
|
|
.first(10)
|
|
.map { |name, minutes| { 'name' => name, 'minutes' => minutes } }
|
|
end
|
|
|
|
def aggregate_city_time_from_monthly_stats
|
|
city_time = Hash.new(0)
|
|
|
|
monthly_stats.each do |stat|
|
|
process_stat_toponyms(stat, city_time)
|
|
end
|
|
|
|
city_time
|
|
end
|
|
|
|
def process_stat_toponyms(stat, city_time)
|
|
toponyms = stat.toponyms
|
|
return unless toponyms.is_a?(Array)
|
|
|
|
toponyms.each do |toponym|
|
|
process_toponym_cities(toponym, city_time)
|
|
end
|
|
end
|
|
|
|
def process_toponym_cities(toponym, city_time)
|
|
return unless toponym.is_a?(Hash)
|
|
return unless toponym['cities'].is_a?(Array)
|
|
|
|
toponym['cities'].each do |city|
|
|
next unless city.is_a?(Hash)
|
|
|
|
stayed_for = city['stayed_for'].to_i
|
|
city_name = city['city']
|
|
|
|
city_time[city_name] += stayed_for if city_name.present?
|
|
end
|
|
end
|
|
|
|
def calculate_first_time_visits
|
|
FirstTimeVisitsCalculator.new(user, year).call
|
|
end
|
|
|
|
def calculate_yoy_comparison
|
|
YearOverYearCalculator.new(user, year).call
|
|
end
|
|
|
|
def calculate_all_time_stats
|
|
{
|
|
'total_countries' => user.countries_visited_uncached.size,
|
|
'total_cities' => user.cities_visited_uncached.size,
|
|
'total_distance' => user.stats.sum(:distance).to_s
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|