From e37cc2b65abd0494da4373426017a5d9b2b45c9d Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Fri, 30 May 2025 05:28:52 +0000 Subject: [PATCH] feat: add last seven days route (#266) --- .../api/hackatime/v1/hackatime_controller.rb | 151 ++++++++++++++++++ config/routes.rb | 1 + db/seeds.rb | 69 ++++++-- 3 files changed, 212 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index aff8f36..31ef8e3 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -50,8 +50,159 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController end end + def stats_last_7_days + Time.use_zone(@user.timezone) do + # Calculate time range within the user's timezone + start_time = (Time.current - 7.days).beginning_of_day + end_time = Time.current.end_of_day + + # Convert to Unix timestamps + start_timestamp = start_time.to_i + end_timestamp = end_time.to_i + + # Get heartbeats in the time range + heartbeats = @user.heartbeats.where(time: start_timestamp..end_timestamp) + + # Calculate total seconds + total_seconds = heartbeats.duration_seconds.to_i + + # Get unique days + days = [] + heartbeats.pluck(:time).each do |timestamp| + day = Time.at(timestamp).in_time_zone(@user.timezone).to_date + days << day unless days.include?(day) + end + days_covered = days.length + + # Calculate daily average + daily_average = days_covered > 0 ? (total_seconds.to_f / days_covered).round(1) : 0 + + # Format human readable strings + hours = total_seconds / 3600 + minutes = (total_seconds % 3600) / 60 + human_readable_total = "#{hours} hrs #{minutes} mins" + + avg_hours = daily_average.to_i / 3600 + avg_minutes = (daily_average.to_i % 3600) / 60 + human_readable_daily_average = "#{avg_hours} hrs #{avg_minutes} mins" + + # Calculate statistics for different categories + editors_data = calculate_category_stats(heartbeats, "editor") + languages_data = calculate_category_stats(heartbeats, "language") + projects_data = calculate_category_stats(heartbeats, "project") + machines_data = calculate_category_stats(heartbeats, "machine") + os_data = calculate_category_stats(heartbeats, "operating_system") + + # Categories data + hours = total_seconds / 3600 + minutes = (total_seconds % 3600) / 60 + seconds = total_seconds % 60 + + categories = [ + { + name: "coding", + total_seconds: total_seconds, + percent: 100.0, + digital: format("%d:%02d:%02d", hours, minutes, seconds), + text: human_readable_total, + hours: hours, + minutes: minutes, + seconds: seconds + } + ] + + result = { + data: { + username: @user.slack_uid, + user_id: @user.slack_uid, + start: start_time.iso8601, + end: end_time.iso8601, + status: "ok", + total_seconds: total_seconds, + daily_average: daily_average, + days_including_holidays: days_covered, + range: "last_7_days", + human_readable_range: "Last 7 Days", + human_readable_total: human_readable_total, + human_readable_daily_average: human_readable_daily_average, + is_coding_activity_visible: true, + is_other_usage_visible: true, + editors: editors_data, + languages: languages_data, + machines: machines_data, + projects: projects_data, + operating_systems: os_data, + categories: categories + } + } + + render json: result + end + end + private + def calculate_category_stats(heartbeats, category) + return [] if heartbeats.empty? + + # Manual calculation approach to avoid SQL issues + category_durations = {} + + # First, group heartbeats by category + grouped_heartbeats = {} + heartbeats.each do |hb| + category_value = hb.send(category) || "unknown" + grouped_heartbeats[category_value] ||= [] + grouped_heartbeats[category_value] << hb + end + + # Calculate duration for each category + grouped_heartbeats.each do |name, hbs| + duration = 0 + hbs = hbs.sort_by(&:time) + + prev_time = nil + hbs.each do |hb| + current_time = hb.time + if prev_time && (current_time - prev_time) <= 120 # 2-minute timeout + duration += (current_time - prev_time) + end + prev_time = current_time + end + + # Add a final 2 minutes for the last heartbeat if we have any + duration += 120 if hbs.any? + + category_durations[name] = duration + end + + # Calculate total duration for percentage calculations + total_duration = category_durations.values.sum.to_f + return [] if total_duration == 0 + + # Format the data for each category + category_durations.map do |name, duration| + name = name.presence || "unknown" + percent = ((duration / total_duration) * 100).round(2) + hours = duration.to_i / 3600 + minutes = (duration.to_i % 3600) / 60 + seconds = duration.to_i % 60 + digital = format("%d:%02d:%02d", hours, minutes, seconds) + text = "#{hours} hrs #{minutes} mins" + + { + name: name, + total_seconds: duration.to_i, + percent: percent, + digital: digital, + text: text, + hours: hours, + minutes: minutes, + seconds: seconds + } + end.sort_by { |item| -item[:total_seconds] } + end + def set_raw_heartbeat_upload @raw_heartbeat_upload = RawHeartbeatUpload.create!( request_headers: headers_to_json, diff --git a/config/routes.rb b/config/routes.rb index 2e7da30..ae89bfe 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -138,6 +138,7 @@ Rails.application.routes.draw do get "/", to: "hackatime#index" # many clients seem to link this as the user's dashboard get "/users/:id/statusbar/today", to: "hackatime#status_bar_today" post "/users/:id/heartbeats", to: "hackatime#push_heartbeats" + get "/users/current/stats/last_7_days", to: "hackatime#stats_last_7_days" end end end diff --git a/db/seeds.rb b/db/seeds.rb index 9c60991..eda300e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -8,10 +8,13 @@ if Rails.env.development? test_user = User.find_or_create_by(slack_uid: 'TEST123456') do |user| user.username = 'testuser' user.is_admin = true + # Ensure timezone is set to avoid nil timezone issues + user.timezone = 'America/New_York' end - # Add email address + # Add email address with slack as the source email = test_user.email_addresses.find_or_create_by(email: 'test@example.com') + email.update(source: :slack) if email.source.nil? # Create API key api_key = test_user.api_keys.find_or_create_by(name: 'Development API Key') do |key| @@ -30,18 +33,66 @@ if Rails.env.development? puts " API Key: #{api_key.token}" puts " Sign-in Token: #{token.token}" - # Create sample heartbeats - if test_user.heartbeats.count == 0 - 5.times do |i| + # Create sample heartbeats for last 7 days with variety of data + if test_user.heartbeats.count < 50 + # Ensure timezone is set + test_user.update!(timezone: 'America/New_York') unless test_user.timezone.present? + + # Create diverse test data over the last 7 days + editors = [ 'Zed', 'Neovim', 'VSCode', 'Emacs' ] + languages = [ 'Ruby', 'JavaScript', 'TypeScript', 'Python', 'Go', 'HTML', 'CSS', 'Markdown' ] + projects = [ 'panorama', 'harbor', 'zera', 'tern', 'smokie' ] + operating_systems = [ 'Linux', 'macOS', 'Windows' ] + machines = [ 'dev-machine', 'laptop', 'desktop' ] + + # Clear existing heartbeats to ensure consistent test data + test_user.heartbeats.destroy_all + + # Create heartbeats for the last 7 days + 7.downto(0) do |day| + # Add 5-20 heartbeats per day + heartbeat_count = rand(5..20) + heartbeat_count.times do |i| + # Distribute throughout the day + hour = rand(9..20) # Between 9 AM and 8 PM + minute = rand(0..59) + second = rand(0..59) + + # Create timestamp for this heartbeat + timestamp = (Time.current - day.days).beginning_of_day + hour.hours + minute.minutes + second.seconds + + # Create the heartbeat with varied data + test_user.heartbeats.create!( + time: timestamp.to_i, + entity: "test/file_#{rand(1..30)}.#{[ 'rb', 'js', 'ts', 'py', 'go' ].sample}", + project: projects.sample, + language: languages.sample, + editor: editors.sample, + operating_system: operating_systems.sample, + machine: machines.sample, + category: "coding", + source_type: :direct_entry + ) + end + end + + # Create a few sequential heartbeats to properly test duration calculation + base_time = Time.current - 2.days + 10.times do |i| test_user.heartbeats.create!( - time: (Time.current - i.hours).to_f, - entity: "test/file_#{i}.rb", - project: "test-project", - language: "ruby", + time: (base_time + i.minutes).to_i, + entity: "test/sequential_file.rb", + project: "harbor", + language: "Ruby", + editor: "Zed", + operating_system: "Linux", + machine: "dev-machine", + category: "coding", source_type: :direct_entry ) end - puts "Created 5 sample heartbeats for the test user" + + puts "Created comprehensive heartbeat data over the last 7 days for the test user" else puts "Sample heartbeats already exist for the test user" end