Skip to content

Commit cfe9f81

Browse files
Add heartbeat download (#457)
1 parent 07ed54a commit cfe9f81

File tree

5 files changed

+297
-1
lines changed

5 files changed

+297
-1
lines changed

.gitattributes

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,8 @@ db/schema.rb linguist-generated
77
vendor/* linguist-vendored
88
config/credentials/*.yml.enc diff=rails_credentials
99
config/credentials.yml.enc diff=rails_credentials
10+
11+
# TWO HOURS. This took 2 HOURS to figure out I hate windows now
12+
# Just makes stuff easy to run on Windows without it yelling at you!
13+
*.sh text eol=lf
14+
bin/* text eol=lf
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
module My
2+
class HeartbeatsController < ApplicationController
3+
before_action :ensure_current_user
4+
5+
6+
def export
7+
all_data = params[:all_data] == "true"
8+
if all_data
9+
heartbeats = current_user.heartbeats.order(time: :asc)
10+
if heartbeats.any?
11+
start_date = Time.at(heartbeats.first.time).to_date
12+
end_date = Time.at(heartbeats.last.time).to_date
13+
else
14+
start_date = Date.current
15+
end_date = Date.current
16+
end
17+
else
18+
start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : 30.days.ago.to_date
19+
end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : Date.current
20+
start_time = start_date.beginning_of_day.to_f
21+
end_time = end_date.end_of_day.to_f
22+
23+
heartbeats = current_user.heartbeats
24+
.where("time >= ? AND time <= ?", start_time, end_time)
25+
.order(time: :asc)
26+
end
27+
28+
29+
export_data = {
30+
export_info: {
31+
exported_at: Time.current.iso8601,
32+
date_range: {
33+
start_date: start_date.iso8601,
34+
end_date: end_date.iso8601
35+
},
36+
total_heartbeats: heartbeats.count,
37+
total_duration_seconds: heartbeats.duration_seconds
38+
},
39+
heartbeats: heartbeats.map do |heartbeat|
40+
{
41+
id: heartbeat.id,
42+
time: Time.at(heartbeat.time).iso8601,
43+
entity: heartbeat.entity,
44+
type: heartbeat.type,
45+
category: heartbeat.category,
46+
project: heartbeat.project,
47+
language: heartbeat.language,
48+
editor: heartbeat.editor,
49+
operating_system: heartbeat.operating_system,
50+
machine: heartbeat.machine,
51+
branch: heartbeat.branch,
52+
user_agent: heartbeat.user_agent,
53+
is_write: heartbeat.is_write,
54+
line_additions: heartbeat.line_additions,
55+
line_deletions: heartbeat.line_deletions,
56+
lineno: heartbeat.lineno,
57+
lines: heartbeat.lines,
58+
cursorpos: heartbeat.cursorpos,
59+
dependencies: heartbeat.dependencies,
60+
source_type: heartbeat.source_type,
61+
created_at: heartbeat.created_at.iso8601,
62+
updated_at: heartbeat.updated_at.iso8601
63+
}
64+
end
65+
}
66+
67+
filename = "heartbeats_#{current_user.slack_uid}_#{start_date.strftime('%Y%m%d')}_#{end_date.strftime('%Y%m%d')}.json"
68+
69+
respond_to do |format|
70+
format.json {
71+
send_data export_data.to_json,
72+
filename: filename,
73+
type: "application/json",
74+
disposition: "attachment"
75+
}
76+
end
77+
end
78+
79+
private
80+
81+
def ensure_current_user
82+
redirect_to root_path, alert: "You must be logged in to view this page!!" unless current_user
83+
end
84+
end
85+
end
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
// The Calender thing is mostly vibe coded, pls check
5+
6+
handleExport(event) {
7+
event.preventDefault()
8+
9+
this.showDateThing()
10+
}
11+
12+
showDateThing() {
13+
const modalHTML = `
14+
<div id="export-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
15+
<div class="bg-darker rounded-lg p-6 max-w-md w-full mx-4 border border-gray-600">
16+
<h3 class="text-xl font-bold text-white mb-4">Export Heartbeats</h3>
17+
<form id="export-form">
18+
<div class="space-y-4">
19+
<div>
20+
<label class="block text-sm font-medium text-secondary mb-2">Start Date</label>
21+
<input type="date" id="start-date"
22+
class="w-full px-3 py-2 bg-darkless border border-gray-600 rounded-lg text-white focus:border-primary focus:outline-none"
23+
value="${this.getDefaultStartDate()}">
24+
</div>
25+
<div>
26+
<label class="block text-sm font-medium text-secondary mb-2">End Date</label>
27+
<input type="date" id="end-date"
28+
class="w-full px-3 py-2 bg-darkless border border-gray-600 rounded-lg text-white focus:border-primary focus:outline-none"
29+
value="${this.getDefaultEndDate()}">
30+
</div>
31+
<div class="flex gap-3 pt-4">
32+
<button type="button" id="cancel-export"
33+
class="flex-1 bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg font-semibold transition-colors">
34+
Cancel
35+
</button>
36+
<button type="submit" id="confirm-export"
37+
class="flex-1 bg-green hover:bg-green-600 text-white px-4 py-2 rounded-lg font-semibold transition-colors">
38+
Export
39+
</button>
40+
</div>
41+
</div>
42+
</form>
43+
</div>
44+
</div>
45+
`
46+
47+
document.body.insertAdjacentHTML("beforeend", modalHTML)
48+
49+
document.getElementById("cancel-export").addEventListener("click", this.closeme)
50+
document.getElementById("export-form").addEventListener("submit", this.exportIT.bind(this))
51+
52+
document.getElementById("export-modal").addEventListener("click", (event) => {
53+
if (event.target.id === "export-modal") {
54+
this.closeme()
55+
}
56+
})
57+
}
58+
getDefaultStartDate() {
59+
const date = new Date()
60+
date.setDate(date.getDate() - 30)
61+
return date.toISOString().split("T")[0]
62+
}
63+
64+
getDefaultEndDate() {
65+
return new Date().toISOString().split("T")[0]
66+
}
67+
68+
closeme() {
69+
const modal = document.getElementById("export-modal")
70+
if (modal) {
71+
modal.remove()
72+
}
73+
}
74+
75+
async exportIT(event) {
76+
event.preventDefault()
77+
78+
const startDate = document.getElementById("start-date").value
79+
const endDate = document.getElementById("end-date").value
80+
81+
if (!startDate || !endDate) {
82+
alert("Please select both start and end dates")
83+
return
84+
}
85+
86+
if (new Date(startDate) > new Date(endDate)) {
87+
alert("Start date must be before end date")
88+
return
89+
}
90+
91+
const submitButton = document.getElementById("confirm-export")
92+
const originalText = submitButton.textContent
93+
submitButton.textContent = "Exporting..."
94+
submitButton.disabled = true
95+
96+
try {
97+
const exportUrl = `/my/heartbeats/export.json?start_date=${startDate}&end_date=${endDate}`
98+
99+
const link = document.createElement("a")
100+
link.href = exportUrl
101+
link.download = `heartbeats_${startDate}_${endDate}.json`
102+
document.body.appendChild(link)
103+
link.click()
104+
document.body.removeChild(link)
105+
106+
setTimeout(() => {
107+
this.closeme()
108+
}, 1000)
109+
110+
} catch (error) {
111+
console.error("Export failed:", error)
112+
alert("Export failed. Please try again. :(")
113+
114+
submitButton.textContent = originalText
115+
submitButton.disabled = false
116+
}
117+
}
118+
119+
120+
}

app/views/users/edit.html.erb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,87 @@
344344
</div>
345345
</div>
346346

347+
<%# This is copied from the github thingie blog, Im not good at UI so I copied :) %>
348+
349+
<div class="border border-primary rounded-xl p-6 bg-dark transition-all duration-200 md:col-span-2">
350+
<div class="flex items-center gap-3 mb-4">
351+
<div class="p-2 bg-red-600/10 rounded">
352+
<span class="text-2xl">💾</span>
353+
</div>
354+
<h2 class="text-xl font-semibold text-white" id="download_user_data">Download Your Data</h2>
355+
</div>
356+
357+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
358+
<div class="space-y-3">
359+
<h3 class="text-lg font-medium text-white">Your Data Overview</h3>
360+
<div class="grid grid-cols-1 gap-4">
361+
<div class="bg-gray-800 border border-gray-600 rounded p-4">
362+
<div class="text-center">
363+
<div class="text-2xl font-bold text-primary mb-1"><%= number_with_delimiter(@user.heartbeats.count) %></div>
364+
<div class="text-sm text-gray-300">Total Heartbeats</div>
365+
</div>
366+
</div>
367+
<div class="bg-gray-800 border border-gray-600 rounded p-4">
368+
<div class="text-center">
369+
<div class="text-2xl font-bold text-orange mb-1"><%= @user.heartbeats.duration_simple %></div>
370+
<div class="text-sm text-gray-300">Total Coding Time</div>
371+
</div>
372+
</div>
373+
<div class="bg-gray-800 border border-gray-600 rounded p-4">
374+
<div class="text-center">
375+
<div class="text-2xl font-bold text-primary mb-1"><%= @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count %></div>
376+
<div class="text-sm text-gray-300">Heartbeats in the Last 7 Days</div>
377+
</div>
378+
</div>
379+
</div>
380+
</div>
381+
382+
<div class="space-y-4">
383+
<h3 class="text-lg font-medium text-white">Export Options</h3>
384+
385+
<div class="bg-gray-800 border border-gray-600 rounded p-4">
386+
<div class="flex items-center gap-2 mb-3">
387+
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
388+
<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" />
389+
</svg>
390+
<h4 class="text-white font-medium">Heartbeat Data</h4>
391+
</div>
392+
<p class="text-gray-300 text-sm mb-3">Export your coding activity as JSON with detailed information about each coding session.</p>
393+
394+
<div class="space-y-2">
395+
<%= link_to export_my_heartbeats_path(format: :json, all_data: "true"),
396+
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",
397+
method: :get do %>
398+
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
399+
<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" />
400+
</svg>
401+
Export All Heartbeats
402+
<% end %>
403+
404+
<%= link_to "#",
405+
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",
406+
data: {
407+
controller: "heartbeat-export",
408+
action: "click->heartbeat-export#handleExport"
409+
} do %>
410+
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
411+
<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" />
412+
</svg>
413+
Export Date Range
414+
<% end %>
415+
</div>
416+
417+
<div class="mt-3 text-xs text-gray-400">
418+
<p><strong>All Heartbeats:</strong> Downloads your complete coding history, from the very start to your last heartbeat</p>
419+
<p><strong>Date Range:</strong> Choose specific dates to export</p>
420+
</div>
421+
</div>
422+
423+
424+
</div>
425+
</div>
426+
</div>
427+
347428
<% admin_tool do %>
348429
<div class="p-6 md:col-span-2">
349430
<div class="flex items-center gap-3 mb-4">

config/routes.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,14 @@ def matches?(request)
107107
post "my/settings/migrate_heartbeats", to: "users#migrate_heartbeats", as: :my_settings_migrate_heartbeats
108108

109109
namespace :my do
110-
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ }
110+
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ }
111111
resource :mailing_address, only: [ :show, :edit ]
112112
get "mailroom", to: "mailroom#index"
113+
resources :heartbeats, only: [] do
114+
collection do
115+
get :export
116+
end
117+
end
113118
end
114119

115120
get "my/wakatime_setup", to: "users#wakatime_setup"

0 commit comments

Comments
 (0)