Add new setup instructions for time tracking

This commit is contained in:
Max Wofford
2025-03-12 19:44:51 -04:00
parent 9bf0be6083
commit 82a5439a6b
16 changed files with 360 additions and 7 deletions

View File

@@ -137,3 +137,15 @@
color: #666;
margin: 0 0 0.1rem;
}
.auto-scroll {
/* flash a little bit yellow & leave a little bit of a border */
animation: flash 1s ease-in-out;
border: 2px solid var(--uchu-yellow);
border-radius: 5px;
}
@keyframes flash {
0% { background-color: var(--uchu-yellow); }
100% { background-color: transparent; }
}

View File

@@ -54,7 +54,14 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
def handle_heartbeat(heartbeat_array)
results = []
heartbeat_array.each do |heartbeat|
attrs = heartbeat.merge({ user_id: @user.id, source_type: :direct_entry })
source_type = :direct_entry
# special case: if the entity is "test.txt", this is a test heartbeat
if heartbeat[:entity] == "test.txt"
source_type = :test_entry
end
attrs = heartbeat.merge({ user_id: @user.id, source_type: source_type })
new_heartbeat = Heartbeat.find_or_create_by(attrs)
results << [ new_heartbeat.attributes, 201 ]
rescue => e

View File

@@ -6,11 +6,10 @@ class Api::V1::My::HeartbeatsController < ApplicationController
.order(time: :desc)
.first
if heartbeat
render json: heartbeat
else
render json: { error: "No heartbeats found" }, status: :not_found
end
render json: {
has_heartbeat: heartbeat.present?,
heartbeat: heartbeat
}
end
def index

View File

@@ -7,6 +7,8 @@ class StaticPagesController < ApplicationController
redirect_to FlavorText.random_time_video.sample, allow_other_host: allowed_hosts
end
@show_wakatime_setup_notice = current_user.heartbeats.empty?
@project_names = current_user.project_names
@projects = current_user.project_labels
@current_project = current_user.active_project

View File

@@ -34,6 +34,19 @@ class UsersController < ApplicationController
notice: "Heartbeats & api keys migration started"
end
def wakatime_setup
api_key = current_user&.api_keys&.last
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
@current_user_api_key = api_key&.token
end
def wakatime_setup_step_2
end
def wakatime_setup_step_3
end
private
def require_admin

View File

@@ -0,0 +1,18 @@
class WakatimeClearTestHeartbeatsJob < ApplicationJob
queue_as :default
include GoodJob::ActiveJobExtensions::Concurrency
# Limits concurrency to 1 job per date
good_job_control_concurrency_with(
key: -> { arguments.first || Date.current.to_s },
total: 1,
drop: true
)
def perform
Heartbeat.where(source_type: "test_entry")
.where("created_at < ?", 7.days.ago)
.delete_all
end
end

View File

@@ -3,4 +3,17 @@ class ApiKey < ApplicationRecord
validates :token, presence: true, uniqueness: true
validates :name, presence: true, uniqueness: { scope: :user_id }
before_validation :generate_token!, on: :create
private
def generate_token!
# we need to keep ourselves compatible with WakaTime: https://github.com/wakatime/vscode-wakatime/blob/241b60c8491c14e3c093b1ef2a0276c38586a172/src/utils.ts#L24
# they use a UUID v4
self.token ||= SecureRandom.uuid_v4
# Mark it as something not imported from WakaTime
self.name ||= "Hackatime key"
end
end

View File

@@ -7,7 +7,8 @@ class Heartbeat < ApplicationRecord
enum :source_type, {
direct_entry: 0,
wakapi_import: 1
wakapi_import: 1,
test_entry: 2
}
# This is to prevent Rails from trying to use STI even though we have a "type" column

View File

@@ -11,6 +11,12 @@
<% end %>
<% if current_user %>
<% if @show_wakatime_setup_notice %>
<p class="subtitle">
<%= link_to "Setup time tracking", my_wakatime_setup_path %>
</p>
<% end %>
<% if @show_logged_time_sentence %>
You've logged
<%= short_time_detailed current_user.heartbeats.today.duration_seconds %>

View File

@@ -4,6 +4,11 @@
<h1><%= @is_own_settings ? "My Settings" : "Settings for #{@user.username}" %></h1>
<div>
<h2>Time tracking wizard</h2>
<%= link_to "Setup time tracking", my_wakatime_setup_path %>
</div>
<div>
<h2>Slack status</h2>
<% unless @can_enable_slack_status %>

View File

@@ -0,0 +1,112 @@
<h1>Wakatime Setup</h1>
<p>Step 1 of 4</p>
<h2>Copy and run the following commands in your terminal</h2>
<div class="setup-instructions">
<h3>Mac/Linux users:</h3>
<pre><code>export HACKATIME_API_KEY="<%= @current_user_api_key %>" && curl -sSL <%= root_url %>hackatime/setup.sh | bash</code></pre>
<h3>Windows users:</h3>
<pre><code>$env:HACKATIME_API_KEY="<%= @current_user_api_key %>"; iwr <%= root_url %>hackatime/setup.ps1 -UseBasicParsing | iex</code></pre>
</div>
<p class="mt-4 text-sm text-gray-600">This will create your WakaTime config file and send a test heartbeat to verify the setup.</p>
<div id="heartbeat-status" class="mt-4">
<p>Waiting for your first heartbeat...</p>
<div class="progress-indicator"></div>
</div>
<p>
<%= link_to "Next Step", my_wakatime_setup_step_2_path, id: "next-step", style: "display: none;" %>
</p>
<script>
document.addEventListener('DOMContentLoaded', function() {
const statusDiv = document.getElementById('heartbeat-status');
const nextStepLink = document.getElementById('next-step');
let checkCount = 0;
const maxChecks = 60; // Stop checking after 5 minutes (60 * 5s = 5min)
function timeAgoInWords(date) {
const diffInSeconds = Math.floor((new Date() - new Date(date)) / 1000);
const diffInMinutes = Math.floor(diffInSeconds / 60);
const diffInHours = Math.floor(diffInMinutes / 60);
const diffInDays = Math.floor(diffInHours / 24);
const diffInMonths = Math.floor(diffInDays / 30);
const diffInYears = Math.floor(diffInDays / 365);
if (diffInYears > 0) {
return `${diffInYears} years ago`;
} else if (diffInMonths > 0) {
return `${diffInMonths} months ago`;
} else if (diffInDays > 0) {
return `${diffInDays} days ago`;
} else if (diffInHours > 0) {
return `${diffInHours} hours ago`;
} else if (diffInMinutes > 0) {
return `${diffInMinutes} minutes ago`;
} else {
return `${diffInSeconds} seconds ago`;
}
}
function checkForHeartbeats() {
fetch('<%= api_v1_my_heartbeats_most_recent_path %>', {
headers: {
'Authorization': 'Bearer <%= @current_user_api_key %>'
}
})
.then(response => response.json())
.then(data => {
if (data.has_heartbeat) {
// show time ago in human readable format
const timeAgo = timeAgoInWords(data.heartbeat.created_at);
statusDiv.innerHTML = `<p class="success">✅ Heartbeat received ${timeAgo}! You can proceed to the next step.</p>`;
nextStepLink.style.display = 'inline-block';
return; // Stop checking once we get a heartbeat
}
throw new Error('No heartbeats yet');
})
.catch(error => {
checkCount++;
if (checkCount >= maxChecks) {
statusDiv.innerHTML = '<p class="error">❌ No heartbeats detected after 5 minutes. Please make sure you\'ve run the command above and have the WakaTime plugin installed in your editor.</p>';
return; // Stop checking after max attempts
}
// Continue checking every 5 seconds
setTimeout(checkForHeartbeats, 5000);
});
}
// Start checking
checkForHeartbeats();
});
</script>
<style>
.progress-indicator {
width: 20px;
height: 20px;
border: 3px solid #eee;
border-top: 3px solid #666;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 10px 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.success {
color: #2da44e;
font-weight: bold;
}
.error {
color: #cf222e;
}
</style>

View File

@@ -0,0 +1,23 @@
<h1>Wakatime Setup</h1>
<p>Step 2 of 4</p>
<h2>Pick your coding editors</h2>
<em>You can change this later!</em>
<ul>
<li>
<%= link_to "vs-code", my_wakatime_setup_step_3_path(anchor: "vs-code") %>
</li>
<li>
<%= link_to "kicad", my_wakatime_setup_step_3_path(anchor: "kicad") %>
</li>
<li>
<%= link_to "vim", my_wakatime_setup_step_3_path(anchor: "vim") %>
</li>
<li>
<%= link_to "emacs", my_wakatime_setup_step_3_path %>
</li>
<li>
<%= link_to "other", my_wakatime_setup_step_3_path %>
</li>
</ul>

View File

@@ -0,0 +1,40 @@
<h1>Wakatime Setup</h1>
<p>Step 3 of 4</p>
<h2>Install!</h2>
<section>
<h2>Other</h2>
<p>
<em>Hackatime is WakaTime-compatible, so you can use it with any editor that WakaTime supports. For other editors, please refer to the <a href="https://wakatime.com/docs/editors">WakaTime docs</a>.</em>
<strong>⚠️ Note: skip any steps that update your ~/.wakatime.cfg file or change the api_url inside it.</strong>
</p>
</section>
<section id="kicad">
<h2>Kicad</h2>
<p>
<em>Hackatime is WakaTime-compatible, so you can use it with any editor that WakaTime supports. For Kicad,
we recommend this <a href="https://github.com/hackclub/kicad-wakatime?tab=readme-ov-file#installation">Kicad plugin built by hack clubbers</a>.</em>
</a>.</em>
</section>
<section id="vs-code">
<h2>VS Code</h2>
<p>
<em>Hackatime is WakaTime-compatible, so you can use it with any editor that WakaTime supports. For VS Code, install the <a href="https://marketplace.visualstudio.com/items?itemName=WakaTime.vscode-wakatime">WakaTime extension</a>.</em>
</p>
</section>
<script>
function autoScroll() {
const editor = window.location.hash.replace("#", "");
if (!editor) { return }
const section = document.getElementById(editor);
if (!section) { return }
section.scrollIntoView({ behavior: "smooth" });
section.classList.add('auto-scroll')
}
autoScroll();
</script>

View File

@@ -56,6 +56,10 @@ Rails.application.routes.draw do
patch "my/settings", to: "users#update"
post "my/settings/migrate_heartbeats", to: "users#migrate_heartbeats", as: :my_settings_migrate_heartbeats
get "my/wakatime_setup", to: "users#wakatime_setup", as: :my_wakatime_setup
get "my/wakatime_setup/step-2", to: "users#wakatime_setup_step_2", as: :my_wakatime_setup_step_2
get "my/wakatime_setup/step-3", to: "users#wakatime_setup_step_3", as: :my_wakatime_setup_step_3
post "/sailors_log/slack/commands", to: "slack#create"
post "/timedump/slack/commands", to: "slack#create"

View File

@@ -0,0 +1,52 @@
# Create config file
New-Item -Path $env:USERPROFILE\.wakatime.cfg -Force
Set-Content -Path $env:USERPROFILE\.wakatime.cfg -Value @"
[settings]
api_url = https://hackatime.hackclub.com/api/hackatime/v1
api_key = $env:HACKATIME_API_KEY
"@
Write-Host "Config file created at $env:USERPROFILE\.wakatime.cfg"
# Read values from config to verify
if (-not (Test-Path $env:USERPROFILE\.wakatime.cfg)) {
Write-Error "Config file not found"
exit 1
}
$config = Get-Content $env:USERPROFILE\.wakatime.cfg
$apiUrl = $config | Select-String "api_url" | ForEach-Object { $_.ToString().Split('"')[1] }
$apiKey = $config | Select-String "api_key" | ForEach-Object { $_.ToString().Split('"')[1] }
if (-not $apiUrl -or -not $apiKey) {
Write-Error "Could not read api_url or api_key from config"
exit 1
}
Write-Host "Successfully read config:"
Write-Host "API URL: $apiUrl"
Write-Host "API Key: $($apiKey.Substring(0,8)..." # Show only first 8 chars for security
# Send test heartbeat using values from config
Write-Host "Sending test heartbeat..."
$time = [Math]::Floor([decimal](Get-Date(Get-Date).ToUniversalTime()-uformat '%s'))
$heartbeat = @{
type = 'file'
time = $time
entity = 'test.txt'
language = 'Text'
}
$body = "[$($heartbeat | ConvertTo-Json)]"
try {
$response = Invoke-WebRequest -Uri "$apiUrl/users/current/heartbeats" `
-Method Post `
-Headers @{Authorization="Bearer $apiKey"} `
-ContentType 'application/json' `
-Body $body
Write-Host "Test heartbeat sent successfully"
} catch {
Write-Error "Error sending heartbeat: $($_.Exception.Response.StatusCode.Value__) $($_.Exception.Message)"
exit 1
}

46
public/hackatime/setup.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
set -e
# Create or update config file
cat > ~/.wakatime.cfg << EOL
[settings]
api_url = https://hackatime.hackclub.com/api/hackatime/v1
api_key = ${HACKATIME_API_KEY}
EOL
echo "Config file created at ~/.wakatime.cfg"
# Read values from config to verify
if [ ! -f ~/.wakatime.cfg ]; then
echo "Error: Config file not found"
exit 1
fi
API_URL=$(sed -n 's/.*api_url = \(.*\)/\1/p' ~/.wakatime.cfg)
API_KEY=$(sed -n 's/.*api_key = \(.*\)/\1/p' ~/.wakatime.cfg)
if [ -z "$API_URL" ] || [ -z "$API_KEY" ]; then
echo "Error: Could not read api_url or api_key from config"
exit 1
fi
echo "Successfully read config:"
echo "API URL: $API_URL"
echo "API Key: ${API_KEY:0:8}..." # Show only first 8 chars for security
# Send test heartbeat using values from config
echo "Sending test heartbeat..."
response=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/users/current/heartbeats" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "[{\"type\":\"file\",\"time\":$(date +%s),\"entity\":\"test.txt\",\"language\":\"Text\"}]")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "200" ] || [ "$http_code" = "202" ]; then
echo -e "\nTest heartbeat sent successfully"
else
echo -e "\nError sending heartbeat: $body"
exit 1
fi