mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Add stats month page
This commit is contained in:
parent
8604effbe1
commit
acd3b20ef5
14 changed files with 845 additions and 53 deletions
File diff suppressed because one or more lines are too long
|
|
@ -16,6 +16,14 @@ class StatsController < ApplicationController
|
|||
@year_distances = { @year => Stat.year_distance(@year, current_user) }
|
||||
end
|
||||
|
||||
def month
|
||||
@year = params[:year].to_i
|
||||
@month = params[:month].to_i
|
||||
@stat = current_user.stats.find_by(year: @year, month: @month)
|
||||
@previous_stat = current_user.stats.find_by(year: @year, month: @month - 1) if @month > 1
|
||||
@average_distance = current_user.stats.average(:distance) / 1000
|
||||
end
|
||||
|
||||
def update
|
||||
if params[:month] == 'all'
|
||||
(1..12).each do |month|
|
||||
|
|
|
|||
|
|
@ -17,14 +17,6 @@ module ApplicationHelper
|
|||
{ start_at:, end_at: }
|
||||
end
|
||||
|
||||
def timespan(month, year)
|
||||
month = DateTime.new(year, month)
|
||||
start_at = month.beginning_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
|
||||
end_at = month.end_of_month.to_time.strftime('%Y-%m-%dT%H:%M')
|
||||
|
||||
{ start_at:, end_at: }
|
||||
end
|
||||
|
||||
def header_colors
|
||||
%w[info success warning error accent secondary primary]
|
||||
end
|
||||
|
|
@ -81,16 +73,6 @@ module ApplicationHelper
|
|||
Stat.convert_distance(total_distance_meters, user.safe_settings.distance_unit)
|
||||
end
|
||||
|
||||
def past?(year, month)
|
||||
DateTime.new(year, month).past?
|
||||
end
|
||||
|
||||
def points_exist?(year, month, user)
|
||||
user.points.where(
|
||||
timestamp: DateTime.new(year, month).beginning_of_month..DateTime.new(year, month).end_of_month
|
||||
).exists?
|
||||
end
|
||||
|
||||
def new_version_available?
|
||||
CheckAppVersion.new.call
|
||||
end
|
||||
|
|
|
|||
121
app/helpers/stats_helper.rb
Normal file
121
app/helpers/stats_helper.rb
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module StatsHelper
|
||||
def distance_traveled(user, stat)
|
||||
distance_unit = user.safe_settings.distance_unit
|
||||
|
||||
value =
|
||||
if distance_unit == 'mi'
|
||||
(stat.distance / 1609.34).round(2)
|
||||
else
|
||||
(stat.distance / 1000).round(2)
|
||||
end
|
||||
|
||||
"#{number_with_delimiter(value)} #{distance_unit}"
|
||||
end
|
||||
|
||||
def x_than_average_distance(stat, average_distance)
|
||||
return '' if average_distance.zero?
|
||||
|
||||
difference = stat.distance / 1000 - average_distance
|
||||
percentage = ((difference / average_distance) * 100).round
|
||||
|
||||
sign = difference.positive? ? '+' : '-'
|
||||
"#{sign} #{difference.abs.round} (#{percentage.abs}% of your average (#{average_distance.round} km))"
|
||||
end
|
||||
|
||||
def x_than_previous_active_days(stat, previous_stat)
|
||||
return '' unless previous_stat
|
||||
|
||||
previous_active_days = previous_stat.daily_distance.select { _1[1].positive? }.count
|
||||
current_active_days = stat.daily_distance.select { _1[1].positive? }.count
|
||||
difference = current_active_days - previous_active_days
|
||||
|
||||
return 'Same as previous month' if difference.zero?
|
||||
|
||||
more_or_less = difference.positive? ? 'more' : 'less'
|
||||
days_word = pluralize(difference.abs, 'day')
|
||||
|
||||
"#{days_word} #{more_or_less} than previous month"
|
||||
end
|
||||
|
||||
def active_days(stat)
|
||||
total_days = stat.daily_distance.count
|
||||
active_days = stat.daily_distance.select { _1[1].positive? }.count
|
||||
|
||||
"#{active_days}/#{total_days}"
|
||||
end
|
||||
|
||||
def countries_visited(stat)
|
||||
stat.toponyms.count { _1['country'] }
|
||||
end
|
||||
|
||||
def x_than_prevopis_countries_visited(stat, previous_stat)
|
||||
return '' unless previous_stat
|
||||
|
||||
previous_countries = previous_stat.toponyms.count { _1['country'] }
|
||||
current_countries = stat.toponyms.count { _1['country'] }
|
||||
difference = current_countries - previous_countries
|
||||
|
||||
return 'Same as previous month' if difference.zero?
|
||||
|
||||
more_or_less = difference.positive? ? 'more' : 'less'
|
||||
countries_word = pluralize(difference.abs, 'country')
|
||||
|
||||
"#{countries_word} #{more_or_less} than previous month"
|
||||
end
|
||||
|
||||
def peak_day(stat)
|
||||
peak = stat.daily_distance.max_by { _1[1] }
|
||||
return 'N/A' unless peak && peak[1].positive?
|
||||
|
||||
date = Date.new(stat.year, stat.month, peak[0])
|
||||
distance_km = (peak[1] / 1000).round(2)
|
||||
distance_unit = current_user.safe_settings.distance_unit
|
||||
|
||||
distance_value =
|
||||
if distance_unit == 'mi'
|
||||
(peak[1] / 1609.34).round(2)
|
||||
else
|
||||
distance_km
|
||||
end
|
||||
|
||||
text = "#{date.strftime('%B %d')} (#{distance_value} #{distance_unit})"
|
||||
|
||||
link_to text, map_url(start_at: date.beginning_of_day, end_at: date.end_of_day), class: 'underline'
|
||||
end
|
||||
|
||||
def quietest_week(stat)
|
||||
return 'N/A' if stat.daily_distance.empty?
|
||||
|
||||
# Create a hash with date as key and distance as value
|
||||
distance_by_date = stat.daily_distance.to_h.transform_keys do |timestamp|
|
||||
Time.at(timestamp).in_time_zone(current_user.timezone || 'UTC').to_date
|
||||
end
|
||||
|
||||
# Initialize variables to track the quietest week
|
||||
quietest_start_date = nil
|
||||
quietest_distance = Float::INFINITY
|
||||
|
||||
# Iterate through each day of the month to find the quietest week
|
||||
start_date = distance_by_date.keys.min.beginning_of_month
|
||||
end_date = distance_by_date.keys.max.end_of_month
|
||||
|
||||
(start_date..end_date).each_cons(7) do |week|
|
||||
week_distance = week.sum { |date| distance_by_date[date] || 0 }
|
||||
|
||||
if week_distance < quietest_distance
|
||||
quietest_distance = week_distance
|
||||
quietest_start_date = week.first
|
||||
end
|
||||
end
|
||||
|
||||
return 'N/A' unless quietest_start_date
|
||||
|
||||
quietest_end_date = quietest_start_date + 6.days
|
||||
start_str = quietest_start_date.strftime('%b %d')
|
||||
end_str = quietest_end_date.strftime('%b %d')
|
||||
|
||||
"#{start_str} - #{end_str}"
|
||||
end
|
||||
end
|
||||
289
app/javascript/controllers/stat_page_controller.js
Normal file
289
app/javascript/controllers/stat_page_controller.js
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
import L from "leaflet";
|
||||
import "leaflet.heat";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["map", "loading", "heatmapBtn", "pointsBtn"];
|
||||
|
||||
connect() {
|
||||
console.log("StatPage controller connected");
|
||||
|
||||
// Get data attributes from the element (will be passed from the view)
|
||||
this.year = parseInt(this.element.dataset.year || new Date().getFullYear());
|
||||
this.month = parseInt(this.element.dataset.month || new Date().getMonth() + 1);
|
||||
this.apiKey = this.element.dataset.apiKey;
|
||||
|
||||
console.log(`Loading data for ${this.month}/${this.year} with API key: ${this.apiKey ? 'present' : 'missing'}`);
|
||||
|
||||
// Initialize map after a short delay to ensure container is ready
|
||||
setTimeout(() => {
|
||||
this.initializeMap();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
}
|
||||
console.log("StatPage controller disconnected");
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
if (!this.mapTarget) {
|
||||
console.error("Map target not found");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize Leaflet map
|
||||
this.map = L.map(this.mapTarget, {
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: true,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
dragging: true,
|
||||
touchZoom: true
|
||||
}).setView([52.520008, 13.404954], 10); // Default to Berlin
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(this.map);
|
||||
|
||||
// Add small scale control
|
||||
L.control.scale({
|
||||
position: 'bottomleft',
|
||||
maxWidth: 100,
|
||||
imperial: false,
|
||||
metric: true
|
||||
}).addTo(this.map);
|
||||
|
||||
// Initialize layers
|
||||
this.markersLayer = L.layerGroup(); // Don't add to map initially
|
||||
this.heatmapLayer = null;
|
||||
|
||||
// Load data for this month
|
||||
this.loadMonthData();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error initializing map:", error);
|
||||
this.showError("Failed to initialize map");
|
||||
}
|
||||
}
|
||||
|
||||
async loadMonthData() {
|
||||
if (!this.apiKey) {
|
||||
console.warn("No API key provided, using mock data");
|
||||
this.loadMockData();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show loading
|
||||
this.showLoading(true);
|
||||
|
||||
// Calculate date range for the month
|
||||
const startDate = `${this.year}-${this.month.toString().padStart(2, '0')}-01T00:00:00`;
|
||||
const lastDay = new Date(this.year, this.month, 0).getDate();
|
||||
const endDate = `${this.year}-${this.month.toString().padStart(2, '0')}-${lastDay}T23:59:59`;
|
||||
|
||||
console.log(`Fetching points from ${startDate} to ${endDate}`);
|
||||
|
||||
// Fetch points data for the month using Authorization header
|
||||
const response = await fetch(`/api/v1/points?start_at=${encodeURIComponent(startDate)}&end_at=${encodeURIComponent(endDate)}&per_page=1000`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`API request failed with status: ${response.status}`);
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`Received ${Array.isArray(data) ? data.length : 0} points from API`);
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
this.processPointsData(data);
|
||||
} else {
|
||||
console.log("No points data available for this month");
|
||||
this.showNoData();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error loading month data:", error);
|
||||
this.showError("Failed to load location data");
|
||||
// Don't fallback to mock data - show the error instead
|
||||
} finally {
|
||||
this.showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
processPointsData(points) {
|
||||
console.log(`Processing ${points.length} points for ${this.month}/${this.year}`);
|
||||
|
||||
// Clear existing markers
|
||||
this.markersLayer.clearLayers();
|
||||
|
||||
// Convert points to markers (API returns latitude/longitude as strings)
|
||||
const markers = points.map(point => {
|
||||
const lat = parseFloat(point.latitude);
|
||||
const lng = parseFloat(point.longitude);
|
||||
|
||||
return L.circleMarker([lat, lng], {
|
||||
radius: 3,
|
||||
fillColor: '#570df8',
|
||||
color: '#570df8',
|
||||
weight: 1,
|
||||
opacity: 0.8,
|
||||
fillOpacity: 0.6
|
||||
});
|
||||
});
|
||||
|
||||
// Add markers to layer (but don't add to map yet)
|
||||
markers.forEach(marker => {
|
||||
this.markersLayer.addLayer(marker);
|
||||
});
|
||||
|
||||
// Prepare data for heatmap (convert strings to numbers)
|
||||
this.heatmapData = points.map(point => [
|
||||
parseFloat(point.latitude),
|
||||
parseFloat(point.longitude),
|
||||
0.5
|
||||
]);
|
||||
|
||||
// Show heatmap by default
|
||||
if (this.heatmapData.length > 0) {
|
||||
this.heatmapLayer = L.heatLayer(this.heatmapData, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
max: 1.0
|
||||
}).addTo(this.map);
|
||||
|
||||
// Set button states
|
||||
this.heatmapBtnTarget.classList.add('btn-active');
|
||||
this.pointsBtnTarget.classList.remove('btn-active');
|
||||
}
|
||||
|
||||
// Fit map to show all points
|
||||
if (points.length > 0) {
|
||||
const group = new L.featureGroup(markers);
|
||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
|
||||
console.log("Points processed successfully");
|
||||
}
|
||||
|
||||
loadMockData() {
|
||||
console.log("Loading mock data for demonstration");
|
||||
|
||||
// Mock data for San Francisco area (hardcoded for demo)
|
||||
const mockPoints = [
|
||||
{ latitude: 37.7749, longitude: -122.4194 },
|
||||
{ latitude: 37.7849, longitude: -122.4094 },
|
||||
{ latitude: 37.7649, longitude: -122.4294 },
|
||||
{ latitude: 37.7949, longitude: -122.3994 },
|
||||
{ latitude: 37.7549, longitude: -122.4394 },
|
||||
{ latitude: 37.8049, longitude: -122.3894 },
|
||||
{ latitude: 37.7449, longitude: -122.4494 },
|
||||
{ latitude: 37.8149, longitude: -122.3794 },
|
||||
{ latitude: 37.7349, longitude: -122.4594 },
|
||||
{ latitude: 37.8249, longitude: -122.3694 }
|
||||
];
|
||||
|
||||
this.processPointsData(mockPoints);
|
||||
this.showLoading(false);
|
||||
}
|
||||
|
||||
toggleHeatmap() {
|
||||
if (!this.heatmapData || this.heatmapData.length === 0) {
|
||||
console.warn("No heatmap data available");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) {
|
||||
// Remove heatmap
|
||||
this.map.removeLayer(this.heatmapLayer);
|
||||
this.heatmapLayer = null;
|
||||
this.heatmapBtnTarget.classList.remove('btn-active');
|
||||
|
||||
// Show points
|
||||
if (!this.map.hasLayer(this.markersLayer)) {
|
||||
this.map.addLayer(this.markersLayer);
|
||||
this.pointsBtnTarget.classList.add('btn-active');
|
||||
}
|
||||
} else {
|
||||
// Add heatmap
|
||||
this.heatmapLayer = L.heatLayer(this.heatmapData, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 17,
|
||||
max: 1.0
|
||||
}).addTo(this.map);
|
||||
|
||||
this.heatmapBtnTarget.classList.add('btn-active');
|
||||
|
||||
// Hide points
|
||||
if (this.map.hasLayer(this.markersLayer)) {
|
||||
this.map.removeLayer(this.markersLayer);
|
||||
this.pointsBtnTarget.classList.remove('btn-active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
togglePoints() {
|
||||
if (this.map.hasLayer(this.markersLayer)) {
|
||||
// Remove points
|
||||
this.map.removeLayer(this.markersLayer);
|
||||
this.pointsBtnTarget.classList.remove('btn-active');
|
||||
} else {
|
||||
// Add points
|
||||
this.map.addLayer(this.markersLayer);
|
||||
this.pointsBtnTarget.classList.add('btn-active');
|
||||
|
||||
// Remove heatmap if active
|
||||
if (this.heatmapLayer && this.map.hasLayer(this.heatmapLayer)) {
|
||||
this.map.removeLayer(this.heatmapLayer);
|
||||
this.heatmapBtnTarget.classList.remove('btn-active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showLoading(show) {
|
||||
if (this.hasLoadingTarget) {
|
||||
this.loadingTarget.style.display = show ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
console.error(message);
|
||||
if (this.hasLoadingTarget) {
|
||||
this.loadingTarget.innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
this.loadingTarget.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
showNoData() {
|
||||
console.log("No data available for this month");
|
||||
if (this.hasLoadingTarget) {
|
||||
this.loadingTarget.innerHTML = `
|
||||
<div class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>No location data available for ${new Date(this.year, this.month - 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</span>
|
||||
</div>
|
||||
`;
|
||||
this.loadingTarget.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,8 @@ class Users::MailerSendingJob < ApplicationJob
|
|||
params = { user: user }.merge(options)
|
||||
|
||||
UsersMailer.with(params).public_send(email_type).deliver_later
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
Rails.logger.warn "User with ID #{user_id} not found. Skipping #{email_type} email."
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength
|
|||
(points_count || 0).zero? && trial?
|
||||
end
|
||||
|
||||
def timezone
|
||||
Time.zone.name
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_api_key
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class CountriesAndCities
|
|||
def call
|
||||
points
|
||||
.reject { |point| point.country_name.nil? || point.city.nil? }
|
||||
.group_by { |point| point.country_name }
|
||||
.group_by(&:country_name)
|
||||
.transform_values { |country_points| process_country_points(country_points) }
|
||||
.map { |country, cities| CountryData.new(country: country, cities: cities) }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,12 +59,13 @@ class Stats::CalculateMonth
|
|||
end
|
||||
|
||||
def toponyms
|
||||
toponym_points = user
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.select(:city, :country_name)
|
||||
.distinct
|
||||
toponym_points =
|
||||
user
|
||||
.points
|
||||
.without_raw_data
|
||||
.where(timestamp: start_timestamp..end_timestamp)
|
||||
.select(:city, :country_name)
|
||||
.distinct
|
||||
|
||||
CountriesAndCities.new(toponym_points).call
|
||||
end
|
||||
|
|
|
|||
282
app/views/stats/_month.html.erb
Normal file
282
app/views/stats/_month.html.erb
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
<!-- Monthly Digest Header -->
|
||||
<div class="hero bg-gradient-to-r from-primary to-secondary text-primary-content rounded-lg shadow-lg mb-8">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">📍 <%= Date::MONTHNAMES[month] %> <%= year %></h1>
|
||||
<p class="py-6">Monthly Digest</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats shadow mx-auto mb-8 w-full">
|
||||
<div class="stat place-items-center text-center">
|
||||
<div class="stat-title">Distance traveled</div>
|
||||
<div class="stat-value"><%= distance_traveled(current_user, stat) %></div>
|
||||
<div class="stat-desc"><%= x_than_average_distance(stat, @average_distance) %></div>
|
||||
</div>
|
||||
|
||||
<div class="stat place-items-center text-center">
|
||||
<div class="stat-title">Active days</div>
|
||||
<div class="stat-value text-secondary">
|
||||
<%= active_days(stat) %>
|
||||
</div>
|
||||
<div class="stat-desc text-secondary">
|
||||
<%= x_than_previous_active_days(stat, previous_stat) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat place-items-center text-center">
|
||||
<div class="stat-title">Countries visited</div>
|
||||
<div class="stat-value">
|
||||
<%= countries_visited(stat) %>
|
||||
</div>
|
||||
<div class="stat-desc">
|
||||
<%= x_than_prevopis_countries_visited(stat, previous_stat) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Summary - Full Width -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8"
|
||||
data-controller="stat-page"
|
||||
data-api-key="<%= current_user.api_key %>"
|
||||
data-year="<%= year %>"
|
||||
data-month="<%= month %>">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">🗺️ Map Summary</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline btn-active" data-stat-page-target="heatmapBtn" data-action="click->stat-page#toggleHeatmap">
|
||||
🔥 Heatmap
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline" data-stat-page-target="pointsBtn" data-action="click->stat-page#togglePoints">
|
||||
📍 Points
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet Map Container -->
|
||||
<div class="w-full h-96 rounded-lg border border-base-300 relative overflow-hidden">
|
||||
<div id="monthly-stats-map" data-stat-page-target="map" class="w-full h-full"></div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div data-stat-page-target="loading" class="absolute inset-0 bg-base-200 flex items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Stats -->
|
||||
<div class="stats grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Most visited</div>
|
||||
<div class="stat-value text-sm">Downtown Area</div>
|
||||
<div class="stat-desc text-xs">42 visits</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Longest trip</div>
|
||||
<div class="stat-value text-sm">156km</div>
|
||||
<div class="stat-desc text-xs">Jan 15th</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Total points</div>
|
||||
<div class="stat-value text-sm">2,847</div>
|
||||
<div class="stat-desc text-xs">tracked locations</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-xs">Coverage area</div>
|
||||
<div class="stat-value text-sm">45km²</div>
|
||||
<div class="stat-desc text-xs">explored</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Activity Chart -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">📈 Daily Activity</h2>
|
||||
<div class="w-full h-48 bg-base-200 rounded-lg p-4 relative">
|
||||
<%= column_chart(
|
||||
stat.daily_distance.map { |day, distance_meters|
|
||||
[day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
|
||||
},
|
||||
height: '200px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Day',
|
||||
ytitle: 'Distance',
|
||||
colors: [
|
||||
'#570df8', '#f000b8', '#ffea00',
|
||||
'#00d084', '#3abff8', '#ff5724',
|
||||
'#8e24aa', '#3949ab', '#00897b',
|
||||
'#d81b60', '#5e35b1', '#039be5',
|
||||
'#43a047', '#f4511e', '#6d4c41',
|
||||
'#757575', '#546e7a', '#d32f2f'
|
||||
],
|
||||
library: {
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(0,0,0,0.1)' }
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(0,0,0,0.1)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
<div class="text-sm opacity-70 text-center mt-2">
|
||||
Peak day: <%= peak_day(stat) %> • Quietest week: <%= quietest_week(stat) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Destinations -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">🏆 Top Destinations</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
|
||||
<div class="text-2xl">🏢</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Downtown Office</div>
|
||||
<div class="text-sm opacity-70">42 visits • 8.5 hrs</div>
|
||||
</div>
|
||||
<div class="badge badge-primary">1st</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
|
||||
<div class="text-2xl">🏠</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Home Area</div>
|
||||
<div class="text-sm opacity-70">31 visits • 156 hrs</div>
|
||||
</div>
|
||||
<div class="badge badge-secondary">2nd</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
|
||||
<div class="text-2xl">🛒</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Shopping District</div>
|
||||
<div class="text-sm opacity-70">18 visits • 3.2 hrs</div>
|
||||
</div>
|
||||
<div class="badge badge-accent">3rd</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4 p-4 bg-base-200 rounded-lg">
|
||||
<div class="text-2xl">✈️</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Airport</div>
|
||||
<div class="text-sm opacity-70">4 visits • 2.1 hrs</div>
|
||||
</div>
|
||||
<div class="badge badge-neutral">4th</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countries & Cities -->
|
||||
<div class="card bg-base-100 shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">🌍 Countries & Cities</h2>
|
||||
<div class="space-y-4">
|
||||
<!-- United States -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold">🇺🇸 United States</span>
|
||||
<span class="text-sm">89% (1,110km)</span>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" value="89" max="100"></progress>
|
||||
</div>
|
||||
|
||||
<!-- Canada -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold">🇨🇦 Canada</span>
|
||||
<span class="text-sm">8% (102km)</span>
|
||||
</div>
|
||||
<progress class="progress progress-secondary w-full" value="8" max="100"></progress>
|
||||
</div>
|
||||
|
||||
<!-- Mexico -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-semibold">🇲🇽 Mexico</span>
|
||||
<span class="text-sm">3% (35km)</span>
|
||||
</div>
|
||||
<progress class="progress progress-accent w-full" value="3" max="100"></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="text-sm font-medium">Cities visited:</span>
|
||||
<div class="badge badge-outline">San Francisco</div>
|
||||
<div class="badge badge-outline">Vancouver</div>
|
||||
<div class="badge badge-outline">Oakland</div>
|
||||
<div class="badge badge-outline">San Jose</div>
|
||||
<div class="badge badge-outline">Berkeley</div>
|
||||
<div class="badge badge-outline">Tijuana</div>
|
||||
<div class="badge badge-outline">+6 more</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Month Highlights -->
|
||||
<div class="card bg-gradient-to-br from-primary to-secondary text-primary-content shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-white">📸 Month Highlights</h2>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 my-4">
|
||||
<div class="stat">
|
||||
<div class="stat-title text-white opacity-70">Photos taken</div>
|
||||
<div class="stat-value text-white">127</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-white opacity-70">Longest trip</div>
|
||||
<div class="stat-value text-white">156km</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-white opacity-70">New areas</div>
|
||||
<div class="stat-value text-white">5</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title text-white opacity-70">Travel time</div>
|
||||
<div class="stat-value text-white">28.5h</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-white">🏃 Walking:</span>
|
||||
<span class="font-bold text-white">45km</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-white">🚌 Public transport:</span>
|
||||
<span class="font-bold text-white">12km</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-white">🚗 Driving:</span>
|
||||
<span class="font-bold text-white">1,190km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert bg-white bg-opacity-10 border-white border-opacity-20">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<div class="text-white">
|
||||
<h3 class="font-bold">💡 Monthly Insights</h3>
|
||||
<p class="text-sm">You explored 3 new neighborhoods this month and visited your favorite coffee shop 15 times - that's every other day! ☕</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-wrap gap-4 mt-8 justify-center">
|
||||
<button class="btn btn-primary">📧 Email This Digest</button>
|
||||
<button class="btn btn-secondary">📊 Compare with Previous Month</button>
|
||||
<button class="btn btn-accent">📱 Share Stats</button>
|
||||
<a href="/stats/<%= year %>" class="btn btn-outline">← Back to <%= year %></a>
|
||||
</div>
|
||||
|
|
@ -1,30 +1,121 @@
|
|||
<div class="border border-gray-500 rounded-md border-opacity-30 bg-gray-100 dark:bg-gray-800 p-3">
|
||||
<div class="flex justify-between">
|
||||
<h4 class="stat-title text-left"><%= Date::MONTHNAMES[stat.month] %> <%= stat.year %></h4>
|
||||
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow duration-300 border border-base-300">
|
||||
<div class="card-body p-6">
|
||||
<!-- Header with Title and Details Link -->
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-primary text-primary-content rounded-full w-12 h-12">
|
||||
<span class="text-sm font-bold"><%= stat.month %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="card-title text-xl font-bold">
|
||||
<%= link_to "#{Date::MONTHNAMES[stat.month]} #{stat.year}",
|
||||
"stats/#{stat.year}/#{stat.month}",
|
||||
class: "hover:text-primary transition-colors" %>
|
||||
</h3>
|
||||
<div class="text-base-content opacity-60 text-sm">Monthly Statistics</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li>
|
||||
<%= link_to points_path(year: stat.year, month: stat.month) do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
View Details
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to "stats/#{stat.year}/#{stat.month}" do %>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
Monthly Digest
|
||||
<% end %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to "Details", points_path(year: stat.year, month: stat.month),
|
||||
class: "link link-primary" %>
|
||||
<!-- Distance Stats -->
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow-inner bg-base-200 mb-6">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-8 h-8 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Total Distance</div>
|
||||
<div class="stat-value text-primary text-2xl">
|
||||
<%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %>
|
||||
</div>
|
||||
<div class="stat-desc font-semibold text-primary">
|
||||
<%= current_user.safe_settings.distance_unit %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Countries and Cities Info -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center space-x-2 mb-3">
|
||||
<div class="badge badge-outline badge-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-4 h-4 stroke-current mr-1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Locations
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed bg-base-200 p-3 rounded-lg">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daily Distance Chart -->
|
||||
<div class="mb-2">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-lg font-semibold flex items-center space-x-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="w-5 h-5 stroke-current text-secondary">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
<span>Daily Activity</span>
|
||||
</h4>
|
||||
<div class="text-xs text-base-content opacity-60 font-medium">
|
||||
<%= current_user.safe_settings.distance_unit %> per day
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-200 p-3 rounded-lg">
|
||||
<%= area_chart(
|
||||
stat.daily_distance.map { |day, distance_meters|
|
||||
[day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
|
||||
},
|
||||
height: '200px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Day',
|
||||
ytitle: 'Distance',
|
||||
colors: ['#570df8'],
|
||||
library: {
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(0,0,0,0.1)' }
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(0,0,0,0.1)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<div class="stat-value">
|
||||
<p><%= number_with_delimiter stat.distance_in_unit(current_user.safe_settings.distance_unit).round %><%= current_user.safe_settings.distance_unit %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-desc">
|
||||
<%= countries_and_cities_stat_for_month(stat) %>
|
||||
</div>
|
||||
|
||||
<%= area_chart(
|
||||
stat.daily_distance.map { |day, distance_meters|
|
||||
[day, Stat.convert_distance(distance_meters, current_user.safe_settings.distance_unit).round]
|
||||
},
|
||||
height: '200px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Day',
|
||||
ytitle: 'Distance'
|
||||
) %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@
|
|||
height: '200px',
|
||||
suffix: " #{current_user.safe_settings.distance_unit}",
|
||||
xtitle: 'Days',
|
||||
ytitle: 'Distance'
|
||||
ytitle: 'Distance',
|
||||
colors: [
|
||||
'#1C3D5A', '#5A4E9D', '#3B945E',
|
||||
'#7BC96F', '#FFD54F', '#FFA94D',
|
||||
'#FF6B6B', '#FF8C42', '#C97E4F',
|
||||
'#8B4513', '#5A2E2E', '#153243'
|
||||
]
|
||||
) %>
|
||||
</div>
|
||||
<div class="mt-5 grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-4">
|
||||
|
|
|
|||
5
app/views/stats/month.html.erb
Normal file
5
app/views/stats/month.html.erb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<% content_for :title, "#{Date::MONTHNAMES[@month]} #{@year} Monthly Digest" %>
|
||||
|
||||
<div class="w-full my-5">
|
||||
<%= render partial: 'stats/month', locals: { year: @year, month: @month, stat: @stat, previous_stat: @previous_stat } %>
|
||||
</div>
|
||||
|
|
@ -70,6 +70,7 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
get 'stats/:year', to: 'stats#show', constraints: { year: /\d{4}/ }
|
||||
get 'stats/:year/:month', to: 'stats#month', constraints: { year: /\d{4}/, month: /\d{1,2}/ }
|
||||
put 'stats/:year/:month/update',
|
||||
to: 'stats#update',
|
||||
as: :update_year_month_stats,
|
||||
|
|
|
|||
Loading…
Reference in a new issue