Update yearly digest layout and styles

This commit is contained in:
Eugene Burmakin 2025-12-27 20:12:35 +01:00
parent c12709ac15
commit da9e440cfa
13 changed files with 126 additions and 94 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-plus2-icon lucide-calendar-plus-2"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M10 16h4"/><path d="M12 14v4"/></svg>

After

Width:  |  Height:  |  Size: 399 B

View file

@ -40,8 +40,7 @@ module Users
end
def aggregate_toponyms
countries = []
cities = []
country_cities = Hash.new { |h, k| h[k] = Set.new }
monthly_stats.each do |stat|
toponyms = stat.toponyms
@ -50,20 +49,27 @@ module Users
toponyms.each do |toponym|
next unless toponym.is_a?(Hash)
countries << toponym['country'] if toponym['country'].present?
country = toponym['country']
next unless country.present?
next unless toponym['cities'].is_a?(Array)
toponym['cities'].each do |city|
cities << city['city'] if city.is_a?(Hash) && city['city'].present?
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
{
'countries' => countries.uniq.compact.sort,
'cities' => cities.uniq.compact.sort
}
country_cities.sort_by { |country, _| country }.map do |country, cities|
{
'country' => country,
'cities' => cities.to_a.sort.map { |city| { 'city' => city } }
}
end
end
def build_monthly_distances

View file

@ -56,8 +56,8 @@ module Users
toponyms = stat.toponyms
next [] unless toponyms.is_a?(Array)
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) }
end.uniq.compact
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) && t['country'].present? }
end.uniq
end
def extract_cities(stats)
@ -68,9 +68,9 @@ module Users
toponyms.flat_map do |t|
next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array)
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) }
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) && c['city'].present? }
end
end.uniq.compact
end.uniq
end
end
end

View file

@ -58,8 +58,8 @@ module Users
toponyms = stat.toponyms
next [] unless toponyms.is_a?(Array)
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) }
end.uniq.compact.count
toponyms.filter_map { |t| t['country'] if t.is_a?(Hash) && t['country'].present? }
end.uniq.count
end
def count_cities(stats)
@ -70,9 +70,9 @@ module Users
toponyms.flat_map do |t|
next [] unless t.is_a?(Hash) && t['cities'].is_a?(Array)
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) }
t['cities'].filter_map { |c| c['city'] if c.is_a?(Hash) && c['city'].present? }
end
end.uniq.compact.count
end.uniq.count
end
end
end

View file

@ -1,6 +1,6 @@
<% content_for :title, 'Year-End Digests' %>
<div class="w-full my-5">
<div class="max-w-xl mx-auto my-5">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold flex items-center gap-2">
<%= icon 'earth' %> Year-End Digests
@ -9,7 +9,7 @@
<% if @available_years.any? && current_user.active? %>
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-primary">
<%= icon 'plus' %> Generate Digest
<%= icon 'calendar-plus-2' %> Generate Digest
</label>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<% @available_years.each do |year| %>

View file

@ -1,4 +1,4 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-xl mx-auto px-4 py-8">
<!-- Header -->
<div class="hero bg-gradient-to-br from-blue-600 to-purple-700 text-white rounded-lg shadow-lg mb-8">
<div class="hero-content text-center py-12">
@ -20,24 +20,24 @@
<div class="stat place-items-center text-center">
<div class="stat-title">Countries visited</div>
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
<% if @digest.first_time_countries.any? %>
<div class="stat-desc text-success"><%= @digest.first_time_countries.count %> first time</div>
<% end %>
<div class="stat-desc <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
<%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
</div>
</div>
<div class="stat place-items-center text-center">
<div class="stat-title">Cities explored</div>
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
<% if @digest.first_time_cities.any? %>
<div class="stat-desc text-success"><%= @digest.first_time_cities.count %> first time</div>
<% end %>
<div class="stat-desc <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
<%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
</div>
</div>
</div>
<!-- First Time Visits -->
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'star' %> First Time Visits
</h2>
@ -45,7 +45,7 @@
<% if @digest.first_time_countries.any? %>
<div class="mb-4">
<h3 class="font-semibold mb-2">New Countries</h3>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_countries.each do |country| %>
<span class="badge badge-success badge-lg"><%= country %></span>
<% end %>
@ -56,7 +56,7 @@
<% if @digest.first_time_cities.any? %>
<div>
<h3 class="font-semibold mb-2">New Cities</h3>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_cities.take(5).each do |city| %>
<span class="badge badge-outline"><%= city %></span>
<% end %>
@ -73,7 +73,7 @@
<!-- Monthly Distance Chart -->
<% if @digest.monthly_distances.present? %>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'activity' %> Year by Month
</h2>
@ -101,11 +101,11 @@
<!-- Top Countries by Time Spent -->
<% if @digest.top_countries_by_time.any? %>
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'map-pin' %> Where They Spent the Most Time
</h2>
<ul class="space-y-2">
<ul class="space-y-2 w-full">
<% @digest.top_countries_by_time.take(3).each do |country| %>
<li class="flex justify-between items-center p-3 bg-base-200 rounded-lg">
<span class="font-semibold"><%= country['name'] %></span>
@ -119,11 +119,11 @@
<!-- Countries & Cities -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4">
<div class="space-y-4 w-full">
<% @digest.toponyms&.each_with_index do |country, index| %>
<div class="space-y-2">
<div class="flex justify-between items-center">
@ -137,7 +137,7 @@
<div class="divider"></div>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 justify-center w-full">
<span class="text-sm font-medium">Cities visited:</span>
<% @digest.toponyms&.each do |country| %>
<% country['cities']&.take(5)&.each do |city| %>
@ -153,23 +153,23 @@
<!-- All-Time Stats -->
<div class="card bg-slate-800 text-white shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title text-white">
<%= icon 'trophy' %> All-Time Stats
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="stat">
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Countries visited</div>
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
</div>
<div class="stat">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Cities explored</div>
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
</div>
<div class="stat">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
<div class="stat place-items-center mt-2">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
<% content_for :title, "#{@digest.year} Year in Review" %>
<div class="w-full my-5">
<div class="max-w-xl mx-auto my-5">
<!-- Header -->
<div class="hero bg-gradient-to-br from-blue-600 to-purple-700 text-white rounded-lg shadow-lg mb-8">
<div class="hero-content text-center py-12 relative w-full">
@ -17,7 +17,7 @@
<!-- Distance Card -->
<div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<div class="stat-title flex items-center gap-2">
<%= icon 'map' %> Distance Traveled
</div>
@ -40,11 +40,9 @@
<%= icon 'globe' %> Countries
</div>
<div class="stat-value text-secondary"><%= @digest.countries_count %></div>
<% if @digest.first_time_countries.any? %>
<div class="stat-desc text-success font-medium">
<%= icon 'star' %> <%= @digest.first_time_countries.count %> first time
</div>
<% end %>
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_countries.any? ? 'text-success' : 'invisible' %>">
<%= icon 'star' %> <%= @digest.first_time_countries.any? ? "#{@digest.first_time_countries.count} first time" : '0 first time' %>
</div>
</div>
<div class="stat place-items-center">
@ -52,18 +50,16 @@
<%= icon 'building' %> Cities
</div>
<div class="stat-value text-accent"><%= @digest.cities_count %></div>
<% if @digest.first_time_cities.any? %>
<div class="stat-desc text-success font-medium">
<%= icon 'star' %> <%= @digest.first_time_cities.count %> first time
</div>
<% end %>
<div class="stat-desc font-medium flex items-center gap-1 <%= @digest.first_time_cities.any? ? 'text-success' : 'invisible' %>">
<%= icon 'star' %> <%= @digest.first_time_cities.any? ? "#{@digest.first_time_cities.count} first time" : '0 first time' %>
</div>
</div>
</div>
<!-- First Time Visits -->
<% if @digest.first_time_countries.any? || @digest.first_time_cities.any? %>
<div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'star' %> First Time Visits
</h2>
@ -71,7 +67,7 @@
<% if @digest.first_time_countries.any? %>
<div class="mb-4">
<h3 class="font-semibold mb-2">New Countries</h3>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_countries.each do |country| %>
<span class="badge badge-success badge-lg"><%= country %></span>
<% end %>
@ -82,7 +78,7 @@
<% if @digest.first_time_cities.any? %>
<div>
<h3 class="font-semibold mb-2">New Cities</h3>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-2 justify-center">
<% @digest.first_time_cities.take(10).each do |city| %>
<span class="badge badge-outline"><%= city %></span>
<% end %>
@ -99,7 +95,7 @@
<!-- Monthly Distance Chart -->
<% if @digest.monthly_distances.present? %>
<div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'activity' %> Your Year, Month by Month
</h2>
@ -127,11 +123,11 @@
<!-- Top Countries by Time Spent -->
<% if @digest.top_countries_by_time.any? %>
<div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'map-pin' %> Where You Spent the Most Time
</h2>
<div class="space-y-4">
<div class="space-y-4 w-full">
<% @digest.top_countries_by_time.take(5).each_with_index do |country, index| %>
<div class="flex justify-between items-center p-3 bg-base-100 rounded-lg">
<div class="flex items-center gap-3">
@ -150,11 +146,11 @@
<!-- All Countries & Cities -->
<div class="card bg-base-200 shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title">
<%= icon 'earth' %> Countries & Cities
</h2>
<div class="space-y-4">
<div class="space-y-4 w-full">
<% if @digest.toponyms.present? %>
<% max_cities = @digest.toponyms.map { |country| country['cities']&.length || 0 }.max %>
<% progress_colors = ['progress-primary', 'progress-secondary', 'progress-accent', 'progress-info', 'progress-success', 'progress-warning'] %>
@ -183,23 +179,23 @@
<!-- All-Time Stats Footer -->
<div class="card bg-slate-800 text-white shadow-xl mb-8">
<div class="card-body">
<div class="card-body text-center items-center">
<h2 class="card-title text-white">
<%= icon 'trophy' %> All-Time Stats
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="stat">
<div class="grid grid-cols-2 gap-4 mt-4">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Countries visited</div>
<div class="stat-value text-white"><%= @digest.total_countries_all_time %></div>
</div>
<div class="stat">
<div class="stat place-items-center">
<div class="stat-title text-gray-400">Cities explored</div>
<div class="stat-value text-white"><%= @digest.total_cities_all_time %></div>
</div>
<div class="stat">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
<div class="stat place-items-center mt-2">
<div class="stat-title text-gray-400">Total distance</div>
<div class="stat-value text-white"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></div>
</div>
</div>
</div>

View file

@ -8,7 +8,7 @@
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
max-width: 480px;
margin: 0 auto;
padding: 0;
background-color: #f5f5f5;
@ -40,6 +40,7 @@
padding: 20px;
margin: 16px 0;
border: 1px solid #e2e8f0;
text-align: center;
}
.stat-value {
font-size: 36px;
@ -113,6 +114,7 @@
border-radius: 12px;
padding: 24px;
margin: 20px 0;
text-align: center;
}
.all-time-footer h3 {
color: white;
@ -120,8 +122,6 @@
font-size: 18px;
}
.all-time-stat {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
@ -130,9 +130,13 @@
}
.all-time-stat .label {
opacity: 0.8;
display: block;
font-size: 12px;
margin-bottom: 4px;
}
.all-time-stat .value {
font-weight: 600;
font-size: 24px;
}
.footer {
text-align: center;
@ -229,15 +233,19 @@
<!-- All-Time Stats Footer -->
<div class="all-time-footer">
<h3>All-Time Stats</h3>
<div class="all-time-stat">
<span class="label">Countries visited</span>
<span class="value"><%= @digest.total_countries_all_time %></span>
</div>
<div class="all-time-stat">
<span class="label">Cities explored</span>
<span class="value"><%= @digest.total_cities_all_time %></span>
</div>
<div class="all-time-stat">
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom: 16px;">
<tr>
<td width="50%" style="text-align: center; padding: 8px;">
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Countries visited</div>
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_countries_all_time %></div>
</td>
<td width="50%" style="text-align: center; padding: 8px;">
<div class="label" style="opacity: 0.8; font-size: 12px; margin-bottom: 4px;">Cities explored</div>
<div class="value" style="font-weight: 600; font-size: 24px;"><%= @digest.total_cities_all_time %></div>
</td>
</tr>
</table>
<div class="all-time-stat" style="border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;">
<span class="label">Total distance</span>
<span class="value"><%= distance_with_unit(@digest.total_distance_all_time, @distance_unit) %></span>
</div>

View file

@ -8,7 +8,7 @@ class CreateDigests < ActiveRecord::Migration[8.0]
t.integer :period_type, null: false, default: 0 # enum: monthly: 0, yearly: 1
# Aggregated data
t.integer :distance, null: false, default: 0 # Total distance in meters
t.bigint :distance, null: false, default: 0 # Total distance in meters
t.jsonb :toponyms, default: {} # Countries/cities data
t.jsonb :monthly_distances, default: {} # {1: meters, 2: meters, ...}
t.jsonb :time_spent_by_location, default: {} # Top locations by time

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class ChangeDigestsDistanceToBigint < ActiveRecord::Migration[8.0]
# Safe: digests table is new with minimal data
disable_ddl_transaction!
def change
if respond_to?(:safety_assured)
safety_assured do
change_column :digests, :distance, :bigint, null: false, default: 0
end
else
change_column :digests, :distance, :bigint, null: false, default: 0
end
end
end

4
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_12_27_000001) do
ActiveRecord::Schema[8.0].define(version: 2025_12_27_193242) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "postgis"
@ -84,7 +84,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_12_27_000001) do
t.bigint "user_id", null: false
t.integer "year", null: false
t.integer "period_type", default: 0, null: false
t.integer "distance", default: 0, null: false
t.bigint "distance", default: 0, null: false
t.jsonb "toponyms", default: {}
t.jsonb "monthly_distances", default: {}
t.jsonb "time_spent_by_location", default: {}

View file

@ -57,12 +57,17 @@ RSpec.describe Users::Digests::CalculateYear do
expect(calculate_digest.distance).to eq(125_000)
end
it 'aggregates countries' do
expect(calculate_digest.toponyms['countries']).to contain_exactly('France', 'Germany')
end
it 'aggregates countries with their cities' do
toponyms = calculate_digest.toponyms
it 'aggregates cities' do
expect(calculate_digest.toponyms['cities']).to contain_exactly('Berlin', 'Munich', 'Paris')
countries = toponyms.map { |t| t['country'] }
expect(countries).to contain_exactly('France', 'Germany')
germany = toponyms.find { |t| t['country'] == 'Germany' }
expect(germany['cities'].map { |c| c['city'] }).to contain_exactly('Berlin', 'Munich')
france = toponyms.find { |t| t['country'] == 'France' }
expect(france['cities'].map { |c| c['city'] }).to contain_exactly('Paris')
end
it 'builds monthly distances' do