From cfe9f813b95d960918a5c8259e8a4734af7bcea7 Mon Sep 17 00:00:00 2001 From: Jeffrey Wang <66625372+JeffreyWangDev@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:36:36 -0400 Subject: [PATCH] Add heartbeat download (#457) --- .gitattributes | 5 + app/controllers/my/heartbeats_controller.rb | 85 +++++++++++++ .../heartbeat_export_controller.js | 120 ++++++++++++++++++ app/views/users/edit.html.erb | 81 ++++++++++++ config/routes.rb | 7 +- 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 app/controllers/my/heartbeats_controller.rb create mode 100644 app/javascript/controllers/heartbeat_export_controller.js diff --git a/.gitattributes b/.gitattributes index 8dc4323..65a3dd3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,8 @@ db/schema.rb linguist-generated vendor/* linguist-vendored config/credentials/*.yml.enc diff=rails_credentials config/credentials.yml.enc diff=rails_credentials + +# TWO HOURS. This took 2 HOURS to figure out I hate windows now +# Just makes stuff easy to run on Windows without it yelling at you! +*.sh text eol=lf +bin/* text eol=lf diff --git a/app/controllers/my/heartbeats_controller.rb b/app/controllers/my/heartbeats_controller.rb new file mode 100644 index 0000000..61811f6 --- /dev/null +++ b/app/controllers/my/heartbeats_controller.rb @@ -0,0 +1,85 @@ +module My + class HeartbeatsController < ApplicationController + before_action :ensure_current_user + + + def export + all_data = params[:all_data] == "true" + if all_data + heartbeats = current_user.heartbeats.order(time: :asc) + if heartbeats.any? + start_date = Time.at(heartbeats.first.time).to_date + end_date = Time.at(heartbeats.last.time).to_date + else + start_date = Date.current + end_date = Date.current + end + else + start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : 30.days.ago.to_date + end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : Date.current + start_time = start_date.beginning_of_day.to_f + end_time = end_date.end_of_day.to_f + + heartbeats = current_user.heartbeats + .where("time >= ? AND time <= ?", start_time, end_time) + .order(time: :asc) + end + + + export_data = { + export_info: { + exported_at: Time.current.iso8601, + date_range: { + start_date: start_date.iso8601, + end_date: end_date.iso8601 + }, + total_heartbeats: heartbeats.count, + total_duration_seconds: heartbeats.duration_seconds + }, + heartbeats: heartbeats.map do |heartbeat| + { + id: heartbeat.id, + time: Time.at(heartbeat.time).iso8601, + entity: heartbeat.entity, + type: heartbeat.type, + category: heartbeat.category, + project: heartbeat.project, + language: heartbeat.language, + editor: heartbeat.editor, + operating_system: heartbeat.operating_system, + machine: heartbeat.machine, + branch: heartbeat.branch, + user_agent: heartbeat.user_agent, + is_write: heartbeat.is_write, + line_additions: heartbeat.line_additions, + line_deletions: heartbeat.line_deletions, + lineno: heartbeat.lineno, + lines: heartbeat.lines, + cursorpos: heartbeat.cursorpos, + dependencies: heartbeat.dependencies, + source_type: heartbeat.source_type, + created_at: heartbeat.created_at.iso8601, + updated_at: heartbeat.updated_at.iso8601 + } + end + } + + filename = "heartbeats_#{current_user.slack_uid}_#{start_date.strftime('%Y%m%d')}_#{end_date.strftime('%Y%m%d')}.json" + + respond_to do |format| + format.json { + send_data export_data.to_json, + filename: filename, + type: "application/json", + disposition: "attachment" + } + end + end + + private + + def ensure_current_user + redirect_to root_path, alert: "You must be logged in to view this page!!" unless current_user + end + end +end diff --git a/app/javascript/controllers/heartbeat_export_controller.js b/app/javascript/controllers/heartbeat_export_controller.js new file mode 100644 index 0000000..4952281 --- /dev/null +++ b/app/javascript/controllers/heartbeat_export_controller.js @@ -0,0 +1,120 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { +// The Calender thing is mostly vibe coded, pls check + + handleExport(event) { + event.preventDefault() + + this.showDateThing() + } + + showDateThing() { + const modalHTML = ` +
+
+

Export Heartbeats

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ ` + + document.body.insertAdjacentHTML("beforeend", modalHTML) + + document.getElementById("cancel-export").addEventListener("click", this.closeme) + document.getElementById("export-form").addEventListener("submit", this.exportIT.bind(this)) + + document.getElementById("export-modal").addEventListener("click", (event) => { + if (event.target.id === "export-modal") { + this.closeme() + } + }) + } + getDefaultStartDate() { + const date = new Date() + date.setDate(date.getDate() - 30) + return date.toISOString().split("T")[0] + } + + getDefaultEndDate() { + return new Date().toISOString().split("T")[0] + } + + closeme() { + const modal = document.getElementById("export-modal") + if (modal) { + modal.remove() + } + } + + async exportIT(event) { + event.preventDefault() + + const startDate = document.getElementById("start-date").value + const endDate = document.getElementById("end-date").value + + if (!startDate || !endDate) { + alert("Please select both start and end dates") + return + } + + if (new Date(startDate) > new Date(endDate)) { + alert("Start date must be before end date") + return + } + + const submitButton = document.getElementById("confirm-export") + const originalText = submitButton.textContent + submitButton.textContent = "Exporting..." + submitButton.disabled = true + + try { + const exportUrl = `/my/heartbeats/export.json?start_date=${startDate}&end_date=${endDate}` + + const link = document.createElement("a") + link.href = exportUrl + link.download = `heartbeats_${startDate}_${endDate}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + setTimeout(() => { + this.closeme() + }, 1000) + + } catch (error) { + console.error("Export failed:", error) + alert("Export failed. Please try again. :(") + + submitButton.textContent = originalText + submitButton.disabled = false + } + } + + +} diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 6e2349a..7e6c571 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -344,6 +344,87 @@ + <%# This is copied from the github thingie blog, Im not good at UI so I copied :) %> + +
+
+
+ 💾 +
+

Download Your Data

+
+ +
+
+

Your Data Overview

+
+
+
+
<%= number_with_delimiter(@user.heartbeats.count) %>
+
Total Heartbeats
+
+
+
+
+
<%= @user.heartbeats.duration_simple %>
+
Total Coding Time
+
+
+
+
+
<%= @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count %>
+
Heartbeats in the Last 7 Days
+
+
+
+
+ +
+

Export Options

+ +
+
+ + + +

Heartbeat Data

+
+

Export your coding activity as JSON with detailed information about each coding session.

+ +
+ <%= link_to export_my_heartbeats_path(format: :json, all_data: "true"), + class: "w-full bg-primary hover:bg-red text-white px-4 py-2 rounded font-medium transition-colors inline-flex items-center justify-center gap-2", + method: :get do %> + + + + Export All Heartbeats + <% end %> + + <%= link_to "#", + class: "w-full bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded font-medium transition-colors inline-flex items-center justify-center gap-2", + data: { + controller: "heartbeat-export", + action: "click->heartbeat-export#handleExport" + } do %> + + + + Export Date Range + <% end %> +
+ +
+

All Heartbeats: Downloads your complete coding history, from the very start to your last heartbeat

+

Date Range: Choose specific dates to export

+
+
+ + +
+
+
+ <% admin_tool do %>
diff --git a/config/routes.rb b/config/routes.rb index 924dc7d..67aa8eb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -107,9 +107,14 @@ Rails.application.routes.draw do post "my/settings/migrate_heartbeats", to: "users#migrate_heartbeats", as: :my_settings_migrate_heartbeats namespace :my do - resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } + resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } resource :mailing_address, only: [ :show, :edit ] get "mailroom", to: "mailroom#index" + resources :heartbeats, only: [] do + collection do + get :export + end + end end get "my/wakatime_setup", to: "users#wakatime_setup"