mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
Add heartbeat download (#457)
This commit is contained in:
parent
07ed54abfc
commit
cfe9f813b9
5 changed files with 297 additions and 1 deletions
5
.gitattributes
vendored
5
.gitattributes
vendored
|
|
@ -7,3 +7,8 @@ db/schema.rb linguist-generated
|
||||||
vendor/* linguist-vendored
|
vendor/* linguist-vendored
|
||||||
config/credentials/*.yml.enc diff=rails_credentials
|
config/credentials/*.yml.enc diff=rails_credentials
|
||||||
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
|
||||||
|
|
|
||||||
85
app/controllers/my/heartbeats_controller.rb
Normal file
85
app/controllers/my/heartbeats_controller.rb
Normal 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
|
||||||
120
app/javascript/controllers/heartbeat_export_controller.js
Normal file
120
app/javascript/controllers/heartbeat_export_controller.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -344,6 +344,87 @@
|
||||||
</div>
|
</div>
|
||||||
</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 %>
|
<% admin_tool do %>
|
||||||
<div class="p-6 md:col-span-2">
|
<div class="p-6 md:col-span-2">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
|
|
||||||
|
|
@ -107,9 +107,14 @@ Rails.application.routes.draw do
|
||||||
post "my/settings/migrate_heartbeats", to: "users#migrate_heartbeats", as: :my_settings_migrate_heartbeats
|
post "my/settings/migrate_heartbeats", to: "users#migrate_heartbeats", as: :my_settings_migrate_heartbeats
|
||||||
|
|
||||||
namespace :my do
|
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 ]
|
resource :mailing_address, only: [ :show, :edit ]
|
||||||
get "mailroom", to: "mailroom#index"
|
get "mailroom", to: "mailroom#index"
|
||||||
|
resources :heartbeats, only: [] do
|
||||||
|
collection do
|
||||||
|
get :export
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
get "my/wakatime_setup", to: "users#wakatime_setup"
|
get "my/wakatime_setup", to: "users#wakatime_setup"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue