Add heartbeat download (#457)

This commit is contained in:
Jeffrey Wang 2025-08-07 15:36:36 -04:00 committed by GitHub
parent 07ed54abfc
commit cfe9f813b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 297 additions and 1 deletions

5
.gitattributes vendored
View file

@ -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

View file

@ -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

View file

@ -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 = `
<div id="export-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-darker rounded-lg p-6 max-w-md w-full mx-4 border border-gray-600">
<h3 class="text-xl font-bold text-white mb-4">Export Heartbeats</h3>
<form id="export-form">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-secondary mb-2">Start Date</label>
<input type="date" id="start-date"
class="w-full px-3 py-2 bg-darkless border border-gray-600 rounded-lg text-white focus:border-primary focus:outline-none"
value="${this.getDefaultStartDate()}">
</div>
<div>
<label class="block text-sm font-medium text-secondary mb-2">End Date</label>
<input type="date" id="end-date"
class="w-full px-3 py-2 bg-darkless border border-gray-600 rounded-lg text-white focus:border-primary focus:outline-none"
value="${this.getDefaultEndDate()}">
</div>
<div class="flex gap-3 pt-4">
<button type="button" id="cancel-export"
class="flex-1 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg font-semibold transition-colors">
Cancel
</button>
<button type="submit" id="confirm-export"
class="flex-1 bg-green hover:bg-green-600 text-white px-4 py-2 rounded-lg font-semibold transition-colors">
Export
</button>
</div>
</div>
</form>
</div>
</div>
`
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
}
}
}

View file

@ -344,6 +344,87 @@
</div>
</div>
<%# This is copied from the github thingie blog, Im not good at UI so I copied :) %>
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-600/10 rounded">
<span class="text-2xl">💾</span>
</div>
<h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-3">
<h3 class="text-lg font-medium text-white">Your Data Overview</h3>
<div class="grid grid-cols-1 gap-4">
<div class="bg-gray-800 border border-gray-600 rounded p-4">
<div class="text-center">
<div class="text-2xl font-bold text-primary mb-1"><%= number_with_delimiter(@user.heartbeats.count) %></div>
<div class="text-sm text-gray-300">Total Heartbeats</div>
</div>
</div>
<div class="bg-gray-800 border border-gray-600 rounded p-4">
<div class="text-center">
<div class="text-2xl font-bold text-orange mb-1"><%= @user.heartbeats.duration_simple %></div>
<div class="text-sm text-gray-300">Total Coding Time</div>
</div>
</div>
<div class="bg-gray-800 border border-gray-600 rounded p-4">
<div class="text-center">
<div class="text-2xl font-bold text-primary mb-1"><%= @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count %></div>
<div class="text-sm text-gray-300">Heartbeats in the Last 7 Days</div>
</div>
</div>
</div>
</div>
<div class="space-y-4">
<h3 class="text-lg font-medium text-white">Export Options</h3>
<div class="bg-gray-800 border border-gray-600 rounded p-4">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<h4 class="text-white font-medium">Heartbeat Data</h4>
</div>
<p class="text-gray-300 text-sm mb-3">Export your coding activity as JSON with detailed information about each coding session.</p>
<div class="space-y-2">
<%= 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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
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 %>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4l6 6m0 0l6-6m-6 6V9" />
</svg>
Export Date Range
<% end %>
</div>
<div class="mt-3 text-xs text-gray-400">
<p><strong>All Heartbeats:</strong> Downloads your complete coding history, from the very start to your last heartbeat</p>
<p><strong>Date Range:</strong> Choose specific dates to export</p>
</div>
</div>
</div>
</div>
</div>
<% admin_tool do %>
<div class="p-6 md:col-span-2">
<div class="flex items-center gap-3 mb-4">

View file

@ -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"