Repository: hackclub/hackatime
Pull request #266: feat: add last seven days route
Author: login: taciturnaxolotl
Created: 2025-05-30T04:07:26Z | Merged: 2025-05-30T05:28:52Z

closes #258
closes #257 


Diff:
diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb
index aff8f36c9..31ef8e3ea 100644
--- a/app/controllers/api/hackatime/v1/hackatime_controller.rb
+++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb
@@ -50,8 +50,159 @@ def status_bar_today
     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 2e7da307f..ae89bfe3b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -138,6 +138,7 @@ def self.matches?(request)
         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 9c6099174..eda300e9b 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -8,10 +8,13 @@
   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 @@
   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


Reviews:
user: login: maxwofford
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-30T04:13:29Z
user: login: maxwofford
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-30T04:13:42Z
user: login: taciturnaxolotl
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-30T04:16:41Z
user: login: taciturnaxolotl
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-30T04:19:26Z

Review comments:
diff_hunk: @@ -3,6 +3,14 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
   skip_before_action :verify_authenticity_token
   before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ]
 
+  STATS_TIME_RANGES = {
+    "last_7_days" => {
+      range: "last_7_days",
+      human_readable_range: "Last 7 Days",
+      calculate: -> { (Time.current - 7.days).beginning_of_day..Time.current.end_of_day }
path: app/controllers/api/hackatime/v1/hackatime_controller.rb
user: login: maxwofford
body: Heads up, you're calling this up here & freezing it, but later you run Time.use_zone(@user.timezone). Are you sure you meant to include this?
created_at: 2025-05-30T04:13:29Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1 @@
+{"data":{"username":"TEST123456","user_id":"TEST123456","start":"2025-05-23T00:00:00Z","end":"2025-05-30T23:59:59Z","status":"ok","total_seconds":12682,"daily_average":1585.3,"days_including_holidays":8,"range":"last_7_days","human_readable_range":"Last 7 Days","human_readable_total":"3 hrs 31 mins","human_readable_daily_average":"0 hrs 26 mins","is_coding_activity_visible":true,"is_other_usage_visible":true,"editors":[{"name":"Zed","total_seconds":11413,"percent":90.12,"digital":"7:13:12","text":"7 hrs 13 mins","hours":7,"minutes":13,"seconds":12},{"name":"Neovim","total_seconds":1268,"percent":9.88,"digital":"0:47:28","text":"0 hrs 47 mins","hours":0,"minutes":47,"seconds":28}],"languages":[{"name":"TypeScript","total_seconds":5072,"percent":40.0,"digital":"3:12:00","text":"3 hrs 12 mins","hours":3,"minutes":12,"seconds":0},{"name":"JavaScript","total_seconds":3804,"percent":30.0,"digital":"2:24:00","text":"2 hrs 24 mins","hours":2,"minutes":24,"seconds":0}],"machines":[{"name":"unknown","total_seconds":12682,"percent":100.0,"digital":"8:00:00","text":"8 hrs 0 mins","hours":8,"minutes":0,"seconds":0}],"projects":[{"name":"harbor","total_seconds":7609,"percent":60.0,"digital":"4:48:00","text":"4 hrs 48 mins","hours":4,"minutes":48,"seconds":0}],"operating_systems":[{"name":"Linux","total_seconds":12682,"percent":100.0,"digital":"8:00:00","text":"8 hrs 0 mins","hours":8,"minutes":0,"seconds":0}],"categories":[{"name":"coding","total_seconds":12682,"percent":100.0,"digital":"3:31:22","text":"3 hrs 31 mins","hours":3,"minutes":31,"seconds":22}]}}
path: test.html
user: login: maxwofford
body: Do we need this file in the repo?
created_at: 2025-05-30T04:13:42Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1 @@
+{"data":{"username":"TEST123456","user_id":"TEST123456","start":"2025-05-23T00:00:00Z","end":"2025-05-30T23:59:59Z","status":"ok","total_seconds":12682,"daily_average":1585.3,"days_including_holidays":8,"range":"last_7_days","human_readable_range":"Last 7 Days","human_readable_total":"3 hrs 31 mins","human_readable_daily_average":"0 hrs 26 mins","is_coding_activity_visible":true,"is_other_usage_visible":true,"editors":[{"name":"Zed","total_seconds":11413,"percent":90.12,"digital":"7:13:12","text":"7 hrs 13 mins","hours":7,"minutes":13,"seconds":12},{"name":"Neovim","total_seconds":1268,"percent":9.88,"digital":"0:47:28","text":"0 hrs 47 mins","hours":0,"minutes":47,"seconds":28}],"languages":[{"name":"TypeScript","total_seconds":5072,"percent":40.0,"digital":"3:12:00","text":"3 hrs 12 mins","hours":3,"minutes":12,"seconds":0},{"name":"JavaScript","total_seconds":3804,"percent":30.0,"digital":"2:24:00","text":"2 hrs 24 mins","hours":2,"minutes":24,"seconds":0}],"machines":[{"name":"unknown","total_seconds":12682,"percent":100.0,"digital":"8:00:00","text":"8 hrs 0 mins","hours":8,"minutes":0,"seconds":0}],"projects":[{"name":"harbor","total_seconds":7609,"percent":60.0,"digital":"4:48:00","text":"4 hrs 48 mins","hours":4,"minutes":48,"seconds":0}],"operating_systems":[{"name":"Linux","total_seconds":12682,"percent":100.0,"digital":"8:00:00","text":"8 hrs 0 mins","hours":8,"minutes":0,"seconds":0}],"categories":[{"name":"coding","total_seconds":12682,"percent":100.0,"digital":"3:31:22","text":"3 hrs 31 mins","hours":3,"minutes":31,"seconds":22}]}}
path: test.html
user: login: taciturnaxolotl
body: oops that wasnt supposed to be there
created_at: 2025-05-30T04:16:41Z
author_association: MEMBER
diff_hunk: @@ -3,6 +3,14 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
   skip_before_action :verify_authenticity_token
   before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ]
 
+  STATS_TIME_RANGES = {
+    "last_7_days" => {
+      range: "last_7_days",
+      human_readable_range: "Last 7 Days",
+      calculate: -> { (Time.current - 7.days).beginning_of_day..Time.current.end_of_day }
path: app/controllers/api/hackatime/v1/hackatime_controller.rb
user: login: taciturnaxolotl
body: ah good catch
created_at: 2025-05-30T04:19:26Z
author_association: MEMBER

PR comments:
user: login: taciturnaxolotl
created_at: 2025-05-30T04:07:36Z
body: tested locally!

![image](https://github.com/user-attachments/assets/95f10623-8fde-41e2-b83e-a2b7b812518a)

author_association: MEMBER
user: login: taciturnaxolotl
created_at: 2025-05-30T04:07:47Z
body: now lets just see if there are any lint errors
author_association: MEMBER
user: login: taciturnaxolotl
created_at: 2025-05-30T04:10:21Z
body: and lint triggered and fixed!
author_association: MEMBER
user: login: taciturnaxolotl
created_at: 2025-05-30T04:10:51Z
body: cc @maxwofford 
author_association: MEMBER
user: login: taciturnaxolotl
created_at: 2025-05-30T04:38:44Z
body: should be all fixed!
author_association: MEMBER
user: login: maxwofford
created_at: 2025-05-30T05:29:23Z
body: just curious, do other integrations use other durations (ie. last 30 days) too?
author_association: MEMBER
user: login: taciturnaxolotl
created_at: 2025-05-30T13:42:37Z
body: Not sure. It wouldn't be terribly hard to add them
author_association: MEMBER