WakaTime/Hackatime v1 imports + Settings v2 (#1062)

* Imports are back!!

* Settings UI v3

* Use Inertia forms for heartbeat imports

* Update app/javascript/pages/Users/Settings/Data.svelte

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update Bundle

* Fix broken Form/Button markup in Data.svelte settings page

* Update JS deps

* Greptile fixes

* Remove dead code

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mahad Kalam 2026-03-12 21:27:10 +00:00 committed by GitHub
parent ea9596cb7d
commit 667d3a7c93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 2748 additions and 3996 deletions

View file

@ -11,9 +11,6 @@ SLACK_SAILORS_LOG_SIGNING_SECRET=your_signing_secret_here
SLACK_SAILORS_LOG_BOT_OAUTH_TOKEN=your_bot_oauth_token_here SLACK_SAILORS_LOG_BOT_OAUTH_TOKEN=your_bot_oauth_token_here
TELETYPE_API_KEY=your_teletype_api_key_here TELETYPE_API_KEY=your_teletype_api_key_here
# Wakatime database url used for migrating data from waka.hackclub.com
WAKATIME_DATABASE_URL=your_wakatime_database_url_here
# You can leave this alone if you're using the provided docker setup! # You can leave this alone if you're using the provided docker setup!
DATABASE_URL=your_database_url_here DATABASE_URL=your_database_url_here
POOL_DATABASE_URL=pg_bouncer_url_here POOL_DATABASE_URL=pg_bouncer_url_here

View file

@ -21,7 +21,6 @@ Edit your `.env` file to include the following:
```env ```env
# Database configurations - these work with the Docker setup # Database configurations - these work with the Docker setup
DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development
WAKATIME_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development
SAILORS_LOG_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development SAILORS_LOG_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development
# Generate these with `rails secret` or use these for development # Generate these with `rails secret` or use these for development

View file

@ -1,7 +1,7 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.16) action_text-trix (2.1.17)
railties railties
actioncable (8.1.2) actioncable (8.1.2)
actionpack (= 8.1.2) actionpack (= 8.1.2)
@ -82,8 +82,8 @@ GEM
ast (2.4.3) ast (2.4.3)
autotuner (1.1.0) autotuner (1.1.0)
aws-eventstream (1.4.0) aws-eventstream (1.4.0)
aws-partitions (1.1220.0) aws-partitions (1.1225.0)
aws-sdk-core (3.242.0) aws-sdk-core (3.243.0)
aws-eventstream (~> 1, >= 1.3.0) aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0) aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9) aws-sigv4 (~> 1.9)
@ -94,8 +94,8 @@ GEM
aws-sdk-kms (1.122.0) aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.213.0) aws-sdk-s3 (1.216.0)
aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-core (~> 3, >= 3.243.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1) aws-sigv4 (1.12.1)
@ -153,7 +153,7 @@ GEM
reline (>= 0.3.8) reline (>= 0.3.8)
diff-lcs (1.6.2) diff-lcs (1.6.2)
domain_name (0.6.20240107) domain_name (0.6.20240107)
doorkeeper (5.8.2) doorkeeper (5.9.0)
railties (>= 5) railties (>= 5)
dotenv (3.2.0) dotenv (3.2.0)
dotenv-rails (3.2.0) dotenv-rails (3.2.0)
@ -173,7 +173,7 @@ GEM
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo tzinfo
faker (3.6.0) faker (3.6.1)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (2.14.1) faraday (2.14.1)
faraday-net_http (>= 2.0, < 3.5) faraday-net_http (>= 2.0, < 3.5)
@ -251,7 +251,7 @@ GEM
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
inertia_rails (3.17.0) inertia_rails (3.18.0)
railties (>= 6) railties (>= 6)
io-console (0.8.2) io-console (0.8.2)
irb (1.17.0) irb (1.17.0)
@ -263,8 +263,8 @@ GEM
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.18.1) json (2.19.1)
json-schema (6.1.0) json-schema (6.2.0)
addressable (~> 2.8) addressable (~> 2.8)
bigdecimal (>= 3.1, < 5) bigdecimal (>= 3.1, < 5)
kamal (2.10.1) kamal (2.10.1)
@ -308,7 +308,7 @@ GEM
activesupport (>= 7.1) activesupport (>= 7.1)
marcel (1.1.0) marcel (1.1.0)
matrix (0.4.3) matrix (0.4.3)
mcp (0.7.1) mcp (0.8.0)
json-schema (>= 4.1) json-schema (>= 4.1)
memory_profiler (1.1.0) memory_profiler (1.1.0)
mini_magick (5.3.1) mini_magick (5.3.1)
@ -374,11 +374,11 @@ GEM
pg (1.6.3-arm64-darwin) pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-linux) pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl) pg (1.6.3-x86_64-linux-musl)
posthog-ruby (3.5.4) posthog-ruby (3.5.5)
concurrent-ruby (~> 1) concurrent-ruby (~> 1)
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
premailer (1.28.0) premailer (1.29.0)
addressable addressable
css_parser (>= 1.19.0) css_parser (>= 1.19.0)
htmlentities (>= 4.0.0) htmlentities (>= 4.0.0)
@ -395,7 +395,7 @@ GEM
psych (5.3.1) psych (5.3.1)
date date
stringio stringio
public_suffix (7.0.2) public_suffix (7.0.5)
puma (7.2.0) puma (7.2.0)
nio4r (~> 2.0) nio4r (~> 2.0)
query_count (1.1.1) query_count (1.1.1)
@ -475,14 +475,14 @@ GEM
rspec-mocks (3.13.8) rspec-mocks (3.13.8)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (8.0.3) rspec-rails (8.0.4)
actionpack (>= 7.2) actionpack (>= 7.2)
activesupport (>= 7.2) activesupport (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
rspec-core (~> 3.13) rspec-core (>= 3.13.0, < 5.0.0)
rspec-expectations (~> 3.13) rspec-expectations (>= 3.13.0, < 5.0.0)
rspec-mocks (~> 3.13) rspec-mocks (>= 3.13.0, < 5.0.0)
rspec-support (~> 3.13) rspec-support (>= 3.13.0, < 5.0.0)
rspec-support (3.13.7) rspec-support (3.13.7)
rswag-api (2.17.0) rswag-api (2.17.0)
activesupport (>= 5.2, < 8.2) activesupport (>= 5.2, < 8.2)
@ -495,7 +495,7 @@ GEM
rswag-ui (2.17.0) rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2) actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2) railties (>= 5.2, < 8.2)
rubocop (1.85.0) rubocop (1.85.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@ -507,7 +507,7 @@ GEM
rubocop-ast (>= 1.49.0, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0) rubocop-ast (1.49.1)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.7) prism (~> 1.7)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
@ -541,10 +541,10 @@ GEM
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (6.4.0) sentry-rails (6.4.1)
railties (>= 5.2.0) railties (>= 5.2.0)
sentry-ruby (~> 6.4.0) sentry-ruby (~> 6.4.1)
sentry-ruby (6.4.0) sentry-ruby (6.4.1)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
logger logger
@ -595,11 +595,11 @@ GEM
tailwindcss-ruby (4.2.0-x86_64-linux-gnu) tailwindcss-ruby (4.2.0-x86_64-linux-gnu)
tailwindcss-ruby (4.2.0-x86_64-linux-musl) tailwindcss-ruby (4.2.0-x86_64-linux-musl)
thor (1.5.0) thor (1.5.0)
thruster (0.1.18) thruster (0.1.19)
thruster (0.1.18-aarch64-linux) thruster (0.1.19-aarch64-linux)
thruster (0.1.18-arm64-darwin) thruster (0.1.19-arm64-darwin)
thruster (0.1.18-x86_64-linux) thruster (0.1.19-x86_64-linux)
timeout (0.6.0) timeout (0.6.1)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.23) turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)

View file

@ -219,7 +219,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
def handle_heartbeat(heartbeat_array) def handle_heartbeat(heartbeat_array)
results = [] results = []
should_enqueue_mirror_sync = false
heartbeat_array.each do |heartbeat| heartbeat_array.each do |heartbeat|
source_type = :direct_entry source_type = :direct_entry
@ -252,7 +251,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
end end
queue_project_mapping(heartbeat[:project]) queue_project_mapping(heartbeat[:project])
results << [ new_heartbeat.attributes, 201 ] results << [ new_heartbeat.attributes, 201 ]
should_enqueue_mirror_sync ||= source_type == :direct_entry
rescue => e rescue => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}") Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}")
@ -260,7 +258,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
end end
PosthogService.capture_once_per_day(@user, "heartbeat_sent", { heartbeat_count: heartbeat_array.size }) PosthogService.capture_once_per_day(@user, "heartbeat_sent", { heartbeat_count: heartbeat_array.size })
enqueue_mirror_sync if should_enqueue_mirror_sync
results results
end end
@ -274,14 +271,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
Rails.logger.error("Error queuing project mapping: #{e.class.name} #{e.message}") Rails.logger.error("Error queuing project mapping: #{e.class.name} #{e.message}")
end end
def enqueue_mirror_sync
return unless Flipper.enabled?(:wakatime_imports_mirrors)
MirrorFanoutEnqueueJob.perform_later(@user.id)
rescue => e
Rails.logger.error("Error enqueuing mirror sync fanout: #{e.class.name} #{e.message}")
end
def check_lockout def check_lockout
return unless @user&.pending_deletion? return unless @user&.pending_deletion?
render json: { error: "Account pending deletion" }, status: :forbidden render json: { error: "Account pending deletion" }, status: :forbidden

View file

@ -68,16 +68,7 @@ class LeaderboardsController < ApplicationController
entries_scope = leaderboard_entries_scope entries_scope = leaderboard_entries_scope
ids = entries_scope.distinct.pluck(:user_id) ids = entries_scope.distinct.pluck(:user_id)
@user_on_leaderboard = current_user && ids.include?(current_user.id) @user_on_leaderboard = current_user && ids.include?(current_user.id)
@untracked_entries = calculate_untracked_entries(ids) unless @user_on_leaderboard || country_scope? @untracked_entries = 0
@total_entries = entries_scope.count @total_entries = entries_scope.count
end end
def calculate_untracked_entries(ids)
return 0 unless Flipper.enabled?(:hackatime_v1_import)
range = @period_type == :last_7_days ? ((Date.current - 6.days).beginning_of_day...Date.current.end_of_day) : Date.current.all_day
ids_set = ids.to_set
Hackatime::Heartbeat.where(time: range).distinct.pluck(:user_id).count { |uid| !ids_set.include?(uid) }
end
end end

View file

@ -1,131 +0,0 @@
class My::HeartbeatImportSourcesController < ApplicationController
before_action :ensure_current_user
before_action :ensure_imports_and_mirrors_enabled
def create
if current_user.heartbeat_import_source.present?
redirect_to my_settings_data_path, alert: "Import source already configured. Update it instead."
return
end
source = current_user.build_heartbeat_import_source(create_params)
source.provider = :wakatime_compatible
source.status = :idle
if source.save
HeartbeatImportSourceSyncJob.perform_later(source.id) if source.sync_enabled?
redirect_to my_settings_data_path, notice: "Import source configured successfully."
else
redirect_to my_settings_data_path, alert: source.errors.full_messages.to_sentence
end
end
def update
source = current_user.heartbeat_import_source
unless source
redirect_to my_settings_data_path, alert: "No import source is configured."
return
end
rerun_backfill = ActiveModel::Type::Boolean.new.cast(params.dig(:heartbeat_import_source, :rerun_backfill))
attrs = update_params
attrs = attrs.except(:encrypted_api_key) if attrs[:encrypted_api_key].blank?
if source.update(attrs)
source.reset_backfill! if rerun_backfill
HeartbeatImportSourceSyncJob.perform_later(source.id) if source.sync_enabled?
redirect_to my_settings_data_path, notice: "Import source updated successfully."
else
redirect_to my_settings_data_path, alert: source.errors.full_messages.to_sentence
end
end
def show
source = current_user.heartbeat_import_source
render json: { import_source: source_payload(source) }
end
def destroy
source = current_user.heartbeat_import_source
unless source
redirect_to my_settings_data_path, alert: "No import source is configured."
return
end
source.destroy
redirect_to my_settings_data_path, notice: "Import source removed."
end
def sync_now
source = current_user.heartbeat_import_source
unless source
redirect_to my_settings_data_path, alert: "No import source is configured."
return
end
unless source.sync_enabled?
redirect_to my_settings_data_path, alert: "Enable sync before running sync now."
return
end
HeartbeatImportSourceSyncJob.perform_later(source.id)
redirect_to my_settings_data_path, notice: "Sync queued."
end
private
def ensure_current_user
redirect_to root_path, alert: "You must be logged in to view this page." unless current_user
end
def ensure_imports_and_mirrors_enabled
return if Flipper.enabled?(:wakatime_imports_mirrors)
if request.format.json?
render json: { error: "Imports and mirrors are currently disabled." }, status: :not_found
else
redirect_to my_settings_data_path, alert: "Imports and mirrors are currently disabled."
end
end
def create_params
base_params.merge(provider: :wakatime_compatible)
end
def update_params
base_params
end
def base_params
params.require(:heartbeat_import_source).permit(
:endpoint_url,
:encrypted_api_key,
:sync_enabled,
:initial_backfill_start_date,
:initial_backfill_end_date
)
end
def source_payload(source)
return nil unless source
{
id: source.id,
provider: source.provider,
endpoint_url: source.endpoint_url,
sync_enabled: source.sync_enabled,
status: source.status,
initial_backfill_start_date: source.initial_backfill_start_date&.iso8601,
initial_backfill_end_date: source.initial_backfill_end_date&.iso8601,
backfill_cursor_date: source.backfill_cursor_date&.iso8601,
last_synced_at: source.last_synced_at&.iso8601,
last_synced_ago: source.last_synced_at ? view_context.time_ago_in_words(source.last_synced_at) : nil,
last_error_message: source.last_error_message,
last_error_at: source.last_error_at&.iso8601,
consecutive_failures: source.consecutive_failures,
imported_count: Rails.cache.fetch("user:#{current_user.id}:wakapi_import_count", expires_in: 5.minutes) do
current_user.heartbeats.where(source_type: :wakapi_import).count
end
}
end
end

View file

@ -1,36 +1,41 @@
class My::HeartbeatImportsController < ApplicationController class My::HeartbeatImportsController < ApplicationController
before_action :ensure_current_user before_action :ensure_current_user
before_action :ensure_development
def create def create
unless params[:heartbeat_file].present? if params[:heartbeat_file].blank? && params[:heartbeat_import].blank?
render json: { error: "pls select a file to import" }, status: :unprocessable_entity redirect_with_import_error("No import data provided.")
return return
end end
file = params[:heartbeat_file] if params[:heartbeat_file].present?
unless valid_json_file?(file) start_dev_upload!
render json: { error: "pls upload only json (download from the button above it)" }, status: :unprocessable_entity else
return start_remote_import!
end end
import_id = HeartbeatImportRunner.start(user: current_user, uploaded_file: file) redirect_to my_settings_data_path
status = HeartbeatImportRunner.status(user: current_user, import_id: import_id) rescue DevelopmentOnlyError => e
redirect_with_import_error(e.message)
render json: { rescue HeartbeatImportRunner::FeatureDisabledError => e
import_id: import_id, redirect_with_import_error(e.message)
status: status rescue HeartbeatImportRunner::CooldownError => e
}, status: :accepted flash[:cooldown_until] = e.retry_at.iso8601
redirect_with_import_error(e.message)
rescue HeartbeatImportRunner::ActiveImportError => e
redirect_with_import_error(e.message)
rescue HeartbeatImportRunner::InvalidProviderError, ActionController::ParameterMissing => e
redirect_with_import_error(e.message)
rescue => e rescue => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
Rails.logger.error("Error starting heartbeat import for user #{current_user&.id}: #{e.message}") Rails.logger.error("Error starting heartbeat import for user #{current_user&.id}: #{e.message}")
render json: { error: "error reading file: #{e.message}" }, status: :internal_server_error redirect_with_import_error("error reading file: #{e.message}")
end end
def show def show
status = HeartbeatImportRunner.status(user: current_user, import_id: params[:id]) run = HeartbeatImportRunner.find_run(user: current_user, import_id: params[:id])
if status.present? if run.present?
render json: status run = HeartbeatImportRunner.refresh_remote_run!(run)
render json: HeartbeatImportRunner.serialize(run)
else else
render json: { error: "Import not found" }, status: :not_found render json: { error: "Import not found" }, status: :not_found
end end
@ -38,10 +43,36 @@ class My::HeartbeatImportsController < ApplicationController
private private
class DevelopmentOnlyError < StandardError; end
def valid_json_file?(file) def valid_json_file?(file)
file.content_type == "application/json" || file.original_filename.to_s.ends_with?(".json") file.content_type == "application/json" || file.original_filename.to_s.ends_with?(".json")
end end
def start_dev_upload!
ensure_development
file = params[:heartbeat_file]
unless valid_json_file?(file)
raise HeartbeatImportRunner::InvalidProviderError, "pls upload only json (download from the button above it)"
end
HeartbeatImportRunner.start_dev_upload(user: current_user, uploaded_file: file)
end
def start_remote_import!
heartbeat_import = remote_import_params
if heartbeat_import[:api_key].blank?
raise HeartbeatImportRunner::InvalidProviderError, "API key is required."
end
HeartbeatImportRunner.start_remote_import(
user: current_user,
provider: heartbeat_import[:provider],
api_key: heartbeat_import[:api_key]
)
end
def ensure_current_user def ensure_current_user
return if current_user return if current_user
@ -51,6 +82,14 @@ class My::HeartbeatImportsController < ApplicationController
def ensure_development def ensure_development
return if Rails.env.development? return if Rails.env.development?
render json: { error: "Heartbeat import is only available in development." }, status: :forbidden raise DevelopmentOnlyError, "Heartbeat import is only available in development."
end
def redirect_with_import_error(message)
redirect_to my_settings_data_path, inertia: { errors: { import: message } }
end
def remote_import_params
params.require(:heartbeat_import).permit(:provider, :api_key)
end end
end end

View file

@ -28,56 +28,6 @@ module My
redirect_to my_settings_data_path, notice: "Your export is being prepared and will be emailed to you." redirect_to my_settings_data_path, notice: "Your export is being prepared and will be emailed to you."
end end
def import
unless Rails.env.development?
redirect_to my_settings_path, alert: "Hey you! This is noit a dev env, STOP DOING THIS!!!!!) Also, idk why this is happning, you should not be able to see this button hmm...."
return
end
unless params[:heartbeat_file].present?
redirect_to my_settings_path, alert: "pls select a file to import"
return
end
file = params[:heartbeat_file]
unless file.content_type == "application/json" || file.original_filename.ends_with?(".json")
redirect_to my_settings_path, alert: "pls upload only json (download from the button above it)"
return
end
begin
file_content = file.read.force_encoding("UTF-8")
rescue => e
redirect_to my_settings_path, alert: "error reading file: #{e.message}"
return
end
result = HeartbeatImportService.import_from_file(file_content, current_user)
if result[:success]
message = "Imported #{result[:imported_count]} out of #{result[:total_count]} heartbeats in #{result[:time_taken]}s"
if result[:skipped_count] > 0
message += " (#{result[:skipped_count]} skipped cause they were duplicates)"
end
if result[:errors].any?
error_count = result[:errors].length
if error_count <= 3
message += ". Errors occurred: #{result[:errors].join("; ")}"
else
message += ". #{error_count} errors occurred. First few: #{result[:errors].first(2).join("; ")}..."
end
end
redirect_to root_path, notice: message
else
error_message = "Import failed: #{result[:error]}"
if result[:errors].any? && result[:errors].length > 1
error_message += "Errors: #{result[:errors][1..2].join("; ")}"
end
redirect_to my_settings_path, alert: error_message
end
end
private private
def ensure_current_user def ensure_current_user

View file

@ -116,7 +116,6 @@ class My::ProjectRepoMappingsController < InertiaController
.group(:repository_id) .group(:repository_id)
.maximum(:created_at) .maximum(:created_at)
archived_names = current_user.project_repo_mappings.archived.pluck(:project_name).index_with(true) archived_names = current_user.project_repo_mappings.archived.pluck(:project_name).index_with(true)
labels_by_project_key = Flipper.enabled?(:hackatime_v1_import) ? current_user.project_labels.pluck(:project_key, :label).to_h : {}
cached = Rails.cache.fetch(project_durations_cache_key, expires_in: 1.minute) do cached = Rails.cache.fetch(project_durations_cache_key, expires_in: 1.minute) do
hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to]) hb = current_user.heartbeats.filter_by_time_range(selected_interval, params[:from], params[:to])
@ -131,7 +130,7 @@ class My::ProjectRepoMappingsController < InertiaController
next if archived_names.key?(project_key) != archived next if archived_names.key?(project_key) != archived
mapping = mappings_by_name[project_key] mapping = mappings_by_name[project_key]
display_name = labels_by_project_key[project_key].presence || project_key.presence || "Unknown" display_name = project_key.presence || "Unknown"
{ {
id: project_card_id(project_key), id: project_card_id(project_key),

View file

@ -29,10 +29,6 @@ class SessionsController < ApplicationController
if @user&.persisted? if @user&.persisted?
session[:user_id] = @user.id session[:user_id] = @user.id
if Flipper.enabled?(:hackatime_v1_import) && @user.data_migration_jobs.empty?
MigrateUserFromHackatimeJob.perform_later(@user.id)
end
PosthogService.identify(@user) PosthogService.identify(@user)
PosthogService.capture(@user, "user_signed_in", { method: "hca" }) PosthogService.capture(@user, "user_signed_in", { method: "hca" })
@ -91,11 +87,6 @@ class SessionsController < ApplicationController
if @user&.persisted? if @user&.persisted?
session[:user_id] = @user.id session[:user_id] = @user.id
if Flipper.enabled?(:hackatime_v1_import) && @user.data_migration_jobs.empty?
# if they don't have a data migration job, add one to the queue
MigrateUserFromHackatimeJob.perform_later(@user.id)
end
PosthogService.identify(@user) PosthogService.identify(@user)
PosthogService.capture(@user, "user_signed_in", { method: "slack" }) PosthogService.capture(@user, "user_signed_in", { method: "slack" })

View file

@ -1,18 +0,0 @@
class Settings::AdminController < Settings::BaseController
before_action :require_admin_section_access
def show
render_settings_page(
active_section: "admin",
settings_update_path: my_settings_profile_path
)
end
private
def require_admin_section_access
unless current_user.admin_level.in?(%w[admin superadmin])
redirect_to my_settings_profile_path, alert: "You are not authorized to access this page"
end
end
end

View file

@ -25,23 +25,19 @@ class Settings::BaseController < InertiaController
"access" => "Users/Settings/Access", "access" => "Users/Settings/Access",
"goals" => "Users/Settings/Goals", "goals" => "Users/Settings/Goals",
"badges" => "Users/Settings/Badges", "badges" => "Users/Settings/Badges",
"data" => "Users/Settings/Data", "data" => "Users/Settings/Data"
"admin" => "Users/Settings/Admin"
}.fetch(active_section.to_s, "Users/Settings/Profile") }.fetch(active_section.to_s, "Users/Settings/Profile")
end end
def prepare_settings_page def prepare_settings_page
@is_own_settings = is_own_settings? @is_own_settings = is_own_settings?
@can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write") @can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write")
@imports_and_mirrors_enabled = Flipper.enabled?(:wakatime_imports_mirrors) @imports_enabled = Flipper.enabled?(:imports, @user)
@enabled_sailors_logs = SailorsLogNotificationPreference.where( @enabled_sailors_logs = SailorsLogNotificationPreference.where(
slack_uid: @user.slack_uid, slack_uid: @user.slack_uid,
enabled: true, enabled: true,
).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS) ).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS)
@heartbeats_migration_jobs = @user.data_migration_jobs
@projects = @user.project_repo_mappings.distinct.pluck(:project_name) @projects = @user.project_repo_mappings.distinct.pluck(:project_name)
heartbeat_language_and_projects = @user.heartbeats.distinct.pluck(:language, :project) heartbeat_language_and_projects = @user.heartbeats.distinct.pluck(:language, :project)
goal_languages = [] goal_languages = []
@ -62,20 +58,12 @@ class Settings::BaseController < InertiaController
@general_badge_url = GithubReadmeStats.new(@user.id, "darcula").generate_badge_url @general_badge_url = GithubReadmeStats.new(@user.id, "darcula").generate_badge_url
@latest_api_key_token = @user.api_keys.last&.token @latest_api_key_token = @user.api_keys.last&.token
@mirrors = @imports_and_mirrors_enabled ? current_user.wakatime_mirrors.order(created_at: :desc) : [] @latest_heartbeat_import = @user.heartbeat_import_runs.latest_first.first
@import_source = @imports_and_mirrors_enabled ? current_user.heartbeat_import_source : nil
end end
def settings_page_props(active_section:, settings_update_path:) def settings_page_props(active_section:, settings_update_path:)
heartbeats_last_7_days = @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count heartbeats_last_7_days = @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count
channel_ids = @enabled_sailors_logs.pluck(:slack_channel_id) channel_ids = @enabled_sailors_logs.pluck(:slack_channel_id)
heartbeat_import_id = nil
heartbeat_import_status = nil
if active_section.to_s == "data" && Rails.env.development?
heartbeat_import_id = params[:heartbeat_import_id].presence
heartbeat_import_status = HeartbeatImportRunner.status(user: @user, import_id: heartbeat_import_id) if heartbeat_import_id
end
{ {
active_section: active_section, active_section: active_section,
@ -86,8 +74,7 @@ class Settings::BaseController < InertiaController
access: my_settings_access_path, access: my_settings_access_path,
goals: my_settings_goals_path, goals: my_settings_goals_path,
badges: my_settings_badges_path, badges: my_settings_badges_path,
data: my_settings_data_path, data: my_settings_data_path
admin: my_settings_admin_path
}, },
page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"), page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"),
heading: (@is_own_settings ? "Settings" : "Settings for #{@user.display_name}"), heading: (@is_own_settings ? "Settings" : "Settings for #{@user.display_name}"),
@ -127,14 +114,10 @@ class Settings::BaseController < InertiaController
add_email_path: add_email_auth_path, add_email_path: add_email_auth_path,
unlink_email_path: unlink_email_auth_path, unlink_email_path: unlink_email_auth_path,
rotate_api_key_path: my_settings_rotate_api_key_path, rotate_api_key_path: my_settings_rotate_api_key_path,
migrate_heartbeats_path: my_settings_migrate_heartbeats_path,
export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"), export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"),
export_range_heartbeats_path: export_my_heartbeats_path, export_range_heartbeats_path: export_my_heartbeats_path,
create_heartbeat_import_path: my_heartbeat_imports_path, create_heartbeat_import_path: my_heartbeat_imports_path,
create_deletion_path: create_deletion_path, create_deletion_path: create_deletion_path
user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user),
heartbeat_import_source_path: my_heartbeat_import_source_path,
heartbeat_import_source_sync_path: sync_my_heartbeat_import_source_path
}, },
options: { options: {
countries: ISO3166::Country.all.map { |country| countries: ISO3166::Country.all.map { |country|
@ -205,38 +188,22 @@ class Settings::BaseController < InertiaController
config_file: { config_file: {
content: generated_wakatime_config(@latest_api_key_token), content: generated_wakatime_config(@latest_api_key_token),
has_api_key: @latest_api_key_token.present?, has_api_key: @latest_api_key_token.present?,
empty_message: "No API key is available yet. Migrate heartbeats or rotate your API key to generate one.", empty_message: "No API key is available yet. Rotate your API key to generate one.",
api_key: @latest_api_key_token, api_key: @latest_api_key_token,
api_url: "https://#{request.host_with_port}/api/hackatime/v1" api_url: "https://#{request.host_with_port}/api/hackatime/v1"
}, },
migration: {
enabled: Flipper.enabled?(:hackatime_v1_import),
jobs: @heartbeats_migration_jobs.map { |job|
{
id: job.id,
status: job.status
}
}
},
data_export: { data_export: {
total_heartbeats: number_with_delimiter(@user.heartbeats.count), total_heartbeats: number_with_delimiter(@user.heartbeats.count),
total_coding_time: @user.heartbeats.duration_simple, total_coding_time: @user.heartbeats.duration_simple,
heartbeats_last_7_days: number_with_delimiter(heartbeats_last_7_days), heartbeats_last_7_days: number_with_delimiter(heartbeats_last_7_days),
is_restricted: (@user.trust_level == "red") is_restricted: (@user.trust_level == "red")
}, },
import_source: serialized_import_source(@import_source), imports_enabled: @imports_enabled,
mirrors: serialized_mirrors(@mirrors), remote_import_cooldown_until: HeartbeatImportRunner.remote_import_cooldown_until(user: @user)&.iso8601,
admin_tools: { latest_heartbeat_import: HeartbeatImportRunner.serialize(@latest_heartbeat_import),
visible: current_user.admin_level.in?(%w[admin superadmin]),
mirrors: serialized_mirrors(@mirrors)
},
ui: { ui: {
show_dev_import: Rails.env.development?, show_dev_import: Rails.env.development?,
show_imports_and_mirrors: @imports_and_mirrors_enabled show_imports: @imports_enabled
},
heartbeat_import: {
import_id: heartbeat_import_id,
status: heartbeat_import_status
}, },
errors: { errors: {
full_messages: @user.errors.full_messages, full_messages: @user.errors.full_messages,
@ -279,43 +246,4 @@ class Settings::BaseController < InertiaController
def is_own_settings? def is_own_settings?
params["id"] == "my" || params["id"]&.blank? params["id"] == "my" || params["id"]&.blank?
end end
def serialized_import_source(source)
return nil unless source
{
id: source.id,
provider: source.provider,
endpoint_url: source.endpoint_url,
sync_enabled: source.sync_enabled,
status: source.status,
initial_backfill_start_date: source.initial_backfill_start_date&.iso8601,
initial_backfill_end_date: source.initial_backfill_end_date&.iso8601,
backfill_cursor_date: source.backfill_cursor_date&.iso8601,
last_synced_at: source.last_synced_at&.iso8601,
last_synced_ago: (source.last_synced_at ? "#{time_ago_in_words(source.last_synced_at)} ago" : "Never"),
last_error_message: source.last_error_message,
last_error_at: source.last_error_at&.iso8601,
consecutive_failures: source.consecutive_failures,
imported_count: Rails.cache.fetch("user:#{source.user_id}:wakapi_import_count", expires_in: 5.minutes) do
source.user.heartbeats.where(source_type: :wakapi_import).count
end
}
end
def serialized_mirrors(mirrors)
mirrors.map { |mirror|
{
id: mirror.id,
endpoint_url: mirror.endpoint_url,
enabled: mirror.enabled,
last_synced_at: mirror.last_synced_at&.iso8601,
last_synced_ago: (mirror.last_synced_at ? "#{time_ago_in_words(mirror.last_synced_at)} ago" : "Never"),
consecutive_failures: mirror.consecutive_failures,
last_error_message: mirror.last_error_message,
last_error_at: mirror.last_error_at&.iso8601,
destroy_path: user_wakatime_mirror_path(current_user, mirror)
}
}
end
end end

View file

@ -3,16 +3,6 @@ class Settings::DataController < Settings::BaseController
render_data render_data
end end
def migrate_heartbeats
unless Flipper.enabled?(:hackatime_v1_import)
redirect_to my_settings_data_path, alert: "Hackatime v1 import is currently disabled"
return
end
MigrateUserFromHackatimeJob.perform_later(@user.id)
redirect_to my_settings_data_path, notice: "Heartbeats & api keys migration started"
end
private private
def render_data(status: :ok) def render_data(status: :ok)

View file

@ -63,11 +63,10 @@ class StaticPagesController < InertiaController
cached = Rails.cache.fetch(key, expires_in: 1.minute) do cached = Rails.cache.fetch(key, expires_in: 1.minute) do
hb = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to]) hb = current_user.heartbeats.filter_by_time_range(params[:interval], params[:from], params[:to])
labels = Flipper.enabled?(:hackatime_v1_import) ? current_user.project_labels : []
projects = hb.group(:project).duration_seconds.filter_map do |proj, dur| projects = hb.group(:project).duration_seconds.filter_map do |proj, dur|
next if dur <= 0 next if dur <= 0
m = @project_repo_mappings.find { |p| p.project_name == proj } m = @project_repo_mappings.find { |p| p.project_name == proj }
{ project: labels.find { |p| p.project_key == proj }&.label || proj || "Unknown", { project: proj || "Unknown",
project_key: proj, repo_url: m&.repo_url, repository: m&.repository, project_key: proj, repo_url: m&.repo_url, repository: m&.repository,
has_mapping: m.present?, duration: dur } has_mapping: m.present?, duration: dur }
end.sort_by { |p| -p[:duration] } end.sort_by { |p| -p[:duration] }

View file

@ -1,47 +0,0 @@
class WakatimeMirrorsController < ApplicationController
before_action :set_user
before_action :require_current_user
before_action :ensure_imports_and_mirrors_enabled
before_action :set_mirror, only: [ :destroy ]
def create
@mirror = @user.wakatime_mirrors.build(mirror_params)
@mirror.request_host = request.host
if @mirror.save
redirect_to my_settings_data_path, notice: "WakaTime mirror added successfully"
else
redirect_to my_settings_data_path, alert: "Failed to add WakaTime mirror: #{@mirror.errors.full_messages.join(', ')}"
end
end
def destroy
@mirror.destroy
redirect_to my_settings_data_path, notice: "WakaTime mirror removed successfully"
end
private
def set_user
@user = User.find(params[:user_id])
end
def set_mirror
@mirror = @user.wakatime_mirrors.find(params[:id])
end
def mirror_params
params.require(:wakatime_mirror).permit(:endpoint_url, :encrypted_api_key)
end
def require_current_user
unless @user == current_user
redirect_to root_path, alert: "You are not authorized to access this page"
end
end
def ensure_imports_and_mirrors_enabled
return if Flipper.enabled?(:wakatime_imports_mirrors)
redirect_to my_settings_data_path, alert: "Imports and mirrors are currently disabled."
end
end

View file

@ -3,6 +3,7 @@
import Button from "../../../components/Button.svelte"; import Button from "../../../components/Button.svelte";
import Modal from "../../../components/Modal.svelte"; import Modal from "../../../components/Modal.svelte";
import Select from "../../../components/Select.svelte"; import Select from "../../../components/Select.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte"; import SettingsShell from "./Shell.svelte";
import type { AccessPageProps } from "./types"; import type { AccessPageProps } from "./types";
@ -18,7 +19,6 @@
paths, paths,
config_file, config_file,
errors, errors,
admin_tools,
}: AccessPageProps = $props(); }: AccessPageProps = $props();
let csrfToken = $state(""); let csrfToken = $state("");
@ -93,116 +93,121 @@
{heading} {heading}
{subheading} {subheading}
{errors} {errors}
{admin_tools}
> >
<div class="space-y-8"> <SectionCard
<section> id="user_tracking_setup"
<h2 class="text-xl font-semibold text-surface-content"> title="Time Tracking Setup"
Time Tracking Setup description="Use the setup guide if you are configuring a new editor or device."
</h2> >
<p class="mt-1 text-sm text-muted"> <p class="text-sm text-muted">
Use the setup guide if you are configuring a new editor or device. Hackatime uses the WakaTime plugin ecosystem, so the setup guide covers
</p> editor installation, API keys, and API URL configuration.
<Button href={paths.wakatime_setup_path} class="mt-4"> </p>
Open setup guide
{#snippet footer()}
<Button href={paths.wakatime_setup_path}>Open setup guide</Button>
{/snippet}
</SectionCard>
<SectionCard
id="user_hackatime_extension"
title="Extension Display"
description="Choose how coding time appears in the extension status text."
>
<form
id="access-extension-form"
method="post"
action={settings_update_path}
class="space-y-4"
>
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label
for="extension_type"
class="mb-2 block text-sm text-surface-content"
>
Display style
</label>
<Select
id="extension_type"
name="user[hackatime_extension_text_type]"
value={user.hackatime_extension_text_type}
items={options.extension_text_types}
/>
</div>
</form>
{#snippet footer()}
<Button type="submit" variant="primary" form="access-extension-form">
Save extension settings
</Button> </Button>
</section> {/snippet}
</SectionCard>
<section id="user_hackatime_extension"> <SectionCard
<h2 class="text-xl font-semibold text-surface-content"> id="user_api_key"
Extension Display title="API Key"
</h2> description="Rotate your API key if you think it has been exposed."
<p class="mt-1 text-sm text-muted"> hasBody={Boolean(rotatedApiKeyError || rotatedApiKey)}
Choose how coding time appears in the extension status text. >
{#if rotatedApiKeyError}
<p
class="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red"
>
{rotatedApiKeyError}
</p> </p>
<form method="post" action={settings_update_path} class="mt-4 space-y-4"> {/if}
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div> {#if rotatedApiKey}
<label <div class="rounded-md border border-surface-200 bg-darker p-3">
for="extension_type" <p class="text-xs font-semibold uppercase tracking-wide text-muted">
class="mb-2 block text-sm text-surface-content" New API key
> </p>
Display style <code class="mt-2 block break-all text-sm text-surface-content"
</label> >{rotatedApiKey}</code
<Select >
id="extension_type" <Button
name="user[hackatime_extension_text_type]" type="button"
value={user.hackatime_extension_text_type} variant="surface"
items={options.extension_text_types} size="xs"
/> class="mt-3"
</div> onclick={copyApiKey}
>
{apiKeyCopied ? "Copied" : "Copy key"}
</Button>
</div>
{/if}
<Button type="submit" variant="primary">Save extension settings</Button> {#snippet footer()}
</form>
</section>
<section id="user_api_key">
<h2 class="text-xl font-semibold text-surface-content">API Key</h2>
<p class="mt-1 text-sm text-muted">
Rotate your API key if you think it has been exposed.
</p>
<Button <Button
type="button" type="button"
class="mt-4"
onclick={openRotateApiKeyModal} onclick={openRotateApiKeyModal}
disabled={rotatingApiKey} disabled={rotatingApiKey}
> >
{rotatingApiKey ? "Rotating..." : "Rotate API key"} {rotatingApiKey ? "Rotating..." : "Rotate API key"}
</Button> </Button>
{/snippet}
</SectionCard>
{#if rotatedApiKeyError} <SectionCard
<p id="user_config_file"
class="mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red" title="WakaTime Config File"
> description="Copy this into your ~/.wakatime.cfg file."
{rotatedApiKeyError} wide
</p> >
{/if} {#if config_file.has_api_key && config_file.content}
<pre
{#if rotatedApiKey} class="overflow-x-auto rounded-md border border-surface-200 bg-darker p-4 text-xs text-surface-content">{config_file.content}</pre>
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-3"> {:else}
<p class="text-xs font-semibold uppercase tracking-wide text-muted"> <p
New API key class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
</p> >
<code class="mt-2 block break-all text-sm text-surface-content" {config_file.empty_message}
>{rotatedApiKey}</code
>
<Button
type="button"
variant="surface"
size="xs"
class="mt-3"
onclick={copyApiKey}
>
{apiKeyCopied ? "Copied" : "Copy key"}
</Button>
</div>
{/if}
</section>
<section id="user_config_file">
<h2 class="text-xl font-semibold text-surface-content">
WakaTime Config File
</h2>
<p class="mt-1 text-sm text-muted">
Copy this into your <code class="rounded bg-darker px-1 py-0.5 text-xs"
>~/.wakatime.cfg</code
> file.
</p> </p>
{/if}
{#if config_file.has_api_key && config_file.content} </SectionCard>
<pre
class="mt-4 overflow-x-auto rounded-md border border-surface-200 bg-darker p-4 text-xs text-surface-content">{config_file.content}</pre>
{:else}
<p
class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
{config_file.empty_message}
</p>
{/if}
</section>
</div>
</SettingsShell> </SettingsShell>
<Modal <Modal

View file

@ -1,40 +0,0 @@
<script lang="ts">
import SettingsShell from "./Shell.svelte";
import type { AdminPageProps } from "./types";
let {
active_section,
section_paths,
page_title,
heading,
subheading,
admin_tools,
errors,
}: AdminPageProps = $props();
</script>
<SettingsShell
{active_section}
{section_paths}
{page_title}
{heading}
{subheading}
{errors}
{admin_tools}
>
{#if admin_tools.visible}
<div class="space-y-3">
<h2 class="text-xl font-semibold text-surface-content">Admin</h2>
<p class="text-sm text-muted">
Mirror and import controls are available under Data settings for all
users.
</p>
</div>
{:else}
<p
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
You are not authorized to access this section.
</p>
{/if}
</SettingsShell>

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Select from "../../../components/Select.svelte"; import Select from "../../../components/Select.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte"; import SettingsShell from "./Shell.svelte";
import type { BadgesPageProps } from "./types"; import type { BadgesPageProps } from "./types";
@ -12,7 +13,6 @@
options, options,
badges, badges,
errors, errors,
admin_tools,
}: BadgesPageProps = $props(); }: BadgesPageProps = $props();
const defaultTheme = (themes: string[]) => const defaultTheme = (themes: string[]) =>
@ -60,124 +60,122 @@
{heading} {heading}
{subheading} {subheading}
{errors} {errors}
{admin_tools}
> >
<div class="space-y-8"> <SectionCard
<section id="user_stats_badges"> id="user_stats_badges"
<h2 class="text-xl font-semibold text-surface-content">Stats Badges</h2> title="Stats Badges"
<p class="mt-1 text-sm text-muted"> description="Generate links for profile badges that display your coding stats."
Generate links for profile badges that display your coding stats. wide
</p> >
<div class="space-y-4">
<div>
<label
for="badge_theme"
class="mb-2 block text-sm text-surface-content"
>
Theme
</label>
<Select
id="badge_theme"
bind:value={selectedTheme}
items={options.badge_themes.map((theme) => ({
value: theme,
label: theme,
}))}
/>
</div>
<div class="mt-4 space-y-4"> <div class="rounded-md border border-surface-200 bg-darker p-4">
<div> <img
<label src={badgeUrl()}
for="badge_theme" alt="General coding stats badge preview"
class="mb-2 block text-sm text-surface-content" class="max-w-full rounded"
> />
Theme <pre
</label> class="mt-3 overflow-x-auto text-xs text-surface-content">{badgeUrl()}</pre>
<Select </div>
id="badge_theme" </div>
bind:value={selectedTheme}
items={options.badge_themes.map((theme) => ({
value: theme,
label: theme,
}))}
/>
</div>
<div class="rounded-md border border-surface-200 bg-darker p-4"> {#if badges.projects.length > 0 && badges.project_badge_base_url}
<div class="mt-6 border-t border-surface-200 pt-6">
<label
for="badge_project"
class="mb-2 block text-sm text-surface-content"
>
Project
</label>
<Select
id="badge_project"
bind:value={selectedProject}
items={badges.projects.map((project) => ({
value: project,
label: project,
}))}
/>
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4">
<img <img
src={badgeUrl()} src={projectBadgeUrl()}
alt="General coding stats badge preview" alt="Project stats badge preview"
class="max-w-full rounded" class="max-w-full rounded"
/> />
<pre <pre
class="mt-3 overflow-x-auto text-xs text-surface-content">{badgeUrl()}</pre> class="mt-3 overflow-x-auto text-xs text-surface-content">{projectBadgeUrl()}</pre>
</div> </div>
</div> </div>
{/if}
</SectionCard>
{#if badges.projects.length > 0 && badges.project_badge_base_url} <SectionCard
<div class="mt-6 border-t border-surface-200 pt-6"> id="user_markscribe"
<label title="Markscribe Template"
for="badge_project" description="Use this snippet with markscribe to include your coding stats in a README."
class="mb-2 block text-sm text-surface-content" wide
> >
Project <div class="rounded-md border border-surface-200 bg-darker p-4">
</label> <pre
<Select class="overflow-x-auto text-sm text-surface-content">{badges.markscribe_template}</pre>
id="badge_project" </div>
bind:value={selectedProject} <p class="mt-3 text-sm text-muted">
items={badges.projects.map((project) => ({ Reference:
value: project, <a
label: project, href={badges.markscribe_reference_url}
}))} target="_blank"
/> class="text-primary underline"
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4"> >
<img markscribe template docs
src={projectBadgeUrl()} </a>
alt="Project stats badge preview" </p>
class="max-w-full rounded" <img
/> src={badges.markscribe_preview_image_url}
<pre alt="Example markscribe output"
class="mt-3 overflow-x-auto text-xs text-surface-content">{projectBadgeUrl()}</pre> class="mt-4 w-full max-w-3xl rounded-md border border-surface-200"
</div> />
</div> </SectionCard>
{/if}
</section>
<section id="user_markscribe"> <SectionCard
<h2 class="text-xl font-semibold text-surface-content"> id="user_heatmap"
Markscribe Template title="Activity Heatmap"
</h2> description="A customizable heatmap for your coding activity."
<p class="mt-1 text-sm text-muted"> wide
Use this snippet with markscribe to include your coding stats in a >
README. <p class="text-sm text-muted">
</p> Configuration:
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4"> <a
<pre class="text-primary underline"
class="overflow-x-auto text-sm text-surface-content">{badges.markscribe_template}</pre> href={badges.heatmap_config_url}
</div> target="_blank">open heatmap builder</a
<p class="mt-3 text-sm text-muted"> >
Reference: </p>
<a <div class="mt-4 rounded-md border border-surface-200 bg-darker p-4 pb-3">
href={badges.markscribe_reference_url} <a href={badges.heatmap_config_url} target="_blank" class="block">
target="_blank" <img
class="text-primary underline" src={badges.heatmap_badge_url}
> alt="Heatmap badge preview"
markscribe template docs class="max-w-full"
</a> />
</p> </a>
<img <pre
src={badges.markscribe_preview_image_url} class="mt-2 overflow-x-auto text-xs text-surface-content">{badges.heatmap_badge_url}</pre>
alt="Example markscribe output" </div>
class="mt-4 w-full max-w-3xl rounded-md border border-surface-200" </SectionCard>
/>
</section>
<section id="user_heatmap">
<h2 class="text-xl font-semibold text-surface-content">
Activity Heatmap
</h2>
<p class="mt-1 text-sm text-muted">
A <a
class="text-primary underline"
href={badges.heatmap_config_url}
target="_blank">customizable</a
> heatmap for your coding activity.
</p>
<div class="mt-4 pb-3 rounded-md border border-surface-200 bg-darker p-4">
<a href={badges.heatmap_config_url} target="_blank" class="block">
<img
src={badges.heatmap_badge_url}
alt="Heatmap badge preview"
class="max-w-full"
/>
</a>
<pre
class="mt-2 overflow-x-auto text-xs text-surface-content">{badges.heatmap_badge_url}</pre>
</div>
</section>
</div>
</SettingsShell> </SettingsShell>

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@
import Modal from "../../../components/Modal.svelte"; import Modal from "../../../components/Modal.svelte";
import MultiSelectCombobox from "../../../components/MultiSelectCombobox.svelte"; import MultiSelectCombobox from "../../../components/MultiSelectCombobox.svelte";
import Select from "../../../components/Select.svelte"; import Select from "../../../components/Select.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte"; import SettingsShell from "./Shell.svelte";
import type { GoalsPageProps, ProgrammingGoal } from "./types"; import type { GoalsPageProps, ProgrammingGoal } from "./types";
@ -26,7 +27,6 @@
user, user,
options, options,
errors, errors,
admin_tools,
goal_form, goal_form,
}: GoalsPageProps = $props(); }: GoalsPageProps = $props();
@ -203,99 +203,94 @@
{heading} {heading}
{subheading} {subheading}
{errors} {errors}
{admin_tools}
> >
<div> <SectionCard
<section id="user_programming_goals"> id="user_programming_goals"
<div class="flex flex-wrap items-center justify-between gap-3"> title="Programming Goals"
<div> description={`Set up to ${MAX_GOALS} goals for your daily, weekly, or monthly coding targets.`}
<h2 class="text-xl font-semibold text-surface-content"> footerClass="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
Programming Goals >
</h2> <div class="flex items-center justify-between gap-3">
<p class="mt-1 text-sm text-muted">Set up to {MAX_GOALS} goals.</p> <p
</div> class="text-xs font-semibold uppercase tracking-wider text-secondary/80 sm:text-sm"
>
{activeGoalSummary}
</p>
</div>
<div class="flex items-center gap-3"> {#if goals.length === 0}
<p <div
class="text-xs font-semibold uppercase tracking-wider text-secondary/80 sm:text-sm" class="mt-4 rounded-md border border-surface-200 bg-darker/30 px-4 py-5 text-center"
> >
{activeGoalSummary} <p class="text-sm text-muted">
</p> Set a goal to track your coding consistency.
<Button </p>
type="button"
variant="primary"
class="rounded-md px-3 py-2"
onclick={openCreateModal}
disabled={hasReachedGoalLimit || submitting}
>
New goal
</Button>
</div>
</div> </div>
{:else}
{#if goals.length === 0} <div
<div class="mt-4 overflow-hidden rounded-md border border-surface-200 bg-darker/30"
class="mt-4 rounded-md border border-surface-200 bg-darker/30 px-4 py-5 text-center" >
> {#each goals as goal (goal.id)}
<p class="text-sm text-muted"> <article
Set a goal to track your coding consistency. class="flex flex-wrap items-start justify-between gap-3 border-b border-surface-200 px-4 py-3 last:border-b-0"
</p>
<Button
type="button"
class="mt-4 rounded-md"
onclick={openCreateModal}
disabled={submitting}
> >
Add new goal... <div class="min-w-0">
</Button> <p class="text-sm font-semibold text-surface-content">
</div> {formatPeriod(goal.period)}: {formatDuration(
{:else} goal.target_seconds,
<div )}
class="mt-4 overflow-hidden rounded-md border border-surface-200 bg-darker/30" </p>
> <p class="mt-1 truncate text-xs text-muted">
{#each goals as goal (goal.id)} {scopeSubtitle(goal)}
<article </p>
class="flex flex-wrap items-start justify-between gap-3 border-b border-surface-200 px-4 py-3 last:border-b-0" </div>
>
<div class="min-w-0">
<p class="text-sm font-semibold text-surface-content">
{formatPeriod(goal.period)}: {formatDuration(
goal.target_seconds,
)}
</p>
<p class="mt-1 truncate text-xs text-muted">
{scopeSubtitle(goal)}
</p>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
type="button" type="button"
variant="surface" variant="surface"
size="xs" size="xs"
class="rounded-md" class="rounded-md"
onclick={() => openEditModal(goal)} onclick={() => openEditModal(goal)}
disabled={submitting} disabled={submitting}
> >
Edit Edit
</Button> </Button>
<Button <Button
type="button" type="button"
variant="surface" variant="surface"
size="xs" size="xs"
class="rounded-md" class="rounded-md"
onclick={() => deleteGoal(goal)} onclick={() => deleteGoal(goal)}
disabled={submitting} disabled={submitting}
> >
Delete Delete
</Button> </Button>
</div> </div>
</article> </article>
{/each} {/each}
</div> </div>
{/if} {/if}
</section>
</div> {#snippet footer()}
<p class="text-sm text-muted">
{#if hasReachedGoalLimit}
Goal limit reached. Delete an existing goal before adding another.
{:else}
Add a goal to stay accountable across languages and projects.
{/if}
</p>
<Button
type="button"
variant="primary"
class="rounded-md px-3 py-2"
onclick={openCreateModal}
disabled={hasReachedGoalLimit || submitting}
>
New goal
</Button>
{/snippet}
</SectionCard>
</SettingsShell> </SettingsShell>
<Modal <Modal

View file

@ -3,6 +3,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import Button from "../../../components/Button.svelte"; import Button from "../../../components/Button.svelte";
import Modal from "../../../components/Modal.svelte"; import Modal from "../../../components/Modal.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte"; import SettingsShell from "./Shell.svelte";
import type { IntegrationsPageProps } from "./types"; import type { IntegrationsPageProps } from "./types";
@ -19,7 +20,6 @@
emails, emails,
paths, paths,
errors, errors,
admin_tools,
}: IntegrationsPageProps = $props(); }: IntegrationsPageProps = $props();
let csrfToken = $state(""); let csrfToken = $state("");
@ -45,27 +45,28 @@
{heading} {heading}
{subheading} {subheading}
{errors} {errors}
{admin_tools}
> >
<div class="space-y-8"> <SectionCard
<section id="user_slack_status"> id="user_slack_status"
<h2 class="text-xl font-semibold text-surface-content"> title="Slack Status Sync"
Slack Status Sync description="Keep your Slack status updated while you are actively coding."
</h2> >
<p class="mt-1 text-sm text-muted"> <div class="space-y-4">
Keep your Slack status updated while you are actively coding.
</p>
{#if !slack.can_enable_status} {#if !slack.can_enable_status}
<a <a
href={paths.slack_auth_path} href={paths.slack_auth_path}
class="mt-4 inline-flex rounded-md border border-surface-200 bg-surface-100 px-3 py-2 text-sm text-surface-content transition-colors hover:bg-surface-200" class="inline-flex rounded-md border border-surface-200 bg-surface-100 px-3 py-2 text-sm text-surface-content transition-colors hover:bg-surface-200"
> >
Re-authorize with Slack Re-authorize with Slack
</a> </a>
{/if} {/if}
<form method="post" action={settings_update_path} class="mt-4 space-y-3"> <form
id="integrations-slack-form"
method="post"
action={settings_update_path}
class="space-y-3"
>
<input type="hidden" name="_method" value="patch" /> <input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} /> <input type="hidden" name="authenticity_token" value={csrfToken} />
@ -83,148 +84,154 @@
</Checkbox.Root> </Checkbox.Root>
Update my Slack status automatically Update my Slack status automatically
</label> </label>
<Button type="submit">Save Slack settings</Button>
</form> </form>
</section> </div>
<section id="user_slack_notifications"> {#snippet footer()}
<h2 class="text-xl font-semibold text-surface-content"> <Button type="submit" form="integrations-slack-form">
Slack Channel Notifications Save Slack settings
</h2> </Button>
<p class="mt-1 text-sm text-muted"> {/snippet}
Enable notifications in any channel by running </SectionCard>
<code
class="rounded bg-darker px-1 py-0.5 text-xs text-surface-content"
>
/sailorslog on
</code>
in that channel.
</p>
{#if slack.notification_channels.length > 0} <SectionCard
<ul class="mt-4 space-y-2"> id="user_slack_notifications"
{#each slack.notification_channels as channel} title="Slack Channel Notifications"
<li description="Enable notifications in any channel by running /sailorslog on in that channel."
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content" >
> <p class="text-sm text-muted">
<a href={channel.url} target="_blank" class="underline" Command:
>{channel.label}</a <code class="rounded bg-darker px-1 py-0.5 text-xs text-surface-content">
> /sailorslog on
</li> </code>
{/each} </p>
</ul>
{:else}
<p
class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
No channel notifications are enabled.
</p>
{/if}
</section>
<section id="user_github_account"> {#if slack.notification_channels.length > 0}
<h2 class="text-xl font-semibold text-surface-content"> <ul class="mt-4 space-y-2">
Connected GitHub Account {#each slack.notification_channels as channel}
</h2> <li
<p class="mt-1 text-sm text-muted"> class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content"
Connect GitHub to show project links in dashboards and leaderboards.
</p>
{#if github.connected && github.username}
<div
class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-3 text-sm text-surface-content"
>
Connected as
<a href={github.profile_url || "#"} target="_blank" class="underline">
@{github.username}
</a>
</div>
<div class="mt-3 flex flex-wrap gap-3">
<Button href={paths.github_auth_path} native class="rounded-md">
Reconnect GitHub
</Button>
<Button
type="button"
variant="surface"
class="rounded-md"
onclick={() => (unlinkGithubModalOpen = true)}
> >
Unlink GitHub <a href={channel.url} target="_blank" class="underline"
</Button> >{channel.label}</a
</div> >
</li>
{/each}
</ul>
{:else}
<p
class="mt-4 rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
No channel notifications are enabled.
</p>
{/if}
</SectionCard>
<SectionCard
id="user_github_account"
title="Connected GitHub Account"
description="Connect GitHub to show project links in dashboards and leaderboards."
hasBody={Boolean(github.connected && github.username)}
>
{#if github.connected && github.username}
<div
class="rounded-md border border-surface-200 bg-darker px-3 py-3 text-sm text-surface-content"
>
Connected as
<a href={github.profile_url || "#"} target="_blank" class="underline">
@{github.username}
</a>
</div>
{/if}
{#snippet footer()}
{#if github.connected && github.username}
<Button href={paths.github_auth_path} native class="rounded-md">
Reconnect GitHub
</Button>
<Button
type="button"
variant="surface"
class="rounded-md"
onclick={() => (unlinkGithubModalOpen = true)}
>
Unlink GitHub
</Button>
{:else} {:else}
<Button href={paths.github_auth_path} native class="mt-4 rounded-md"> <Button href={paths.github_auth_path} native class="rounded-md">
Connect GitHub Connect GitHub
</Button> </Button>
{/if} {/if}
</section> {/snippet}
</SectionCard>
<section id="user_email_addresses"> <SectionCard
<h2 class="text-xl font-semibold text-surface-content"> id="user_email_addresses"
Email Addresses title="Email Addresses"
</h2> description="Add or remove email addresses used for sign-in and verification."
<p class="mt-1 text-sm text-muted"> >
Add or remove email addresses used for sign-in and verification. <div class="space-y-2">
</p> {#if emails.length > 0}
{#each emails as email}
<div class="mt-4 space-y-2"> <div
{#if emails.length > 0} class="flex flex-wrap items-center gap-2 rounded-md border border-surface-200 bg-darker px-3 py-2"
{#each emails as email}
<div
class="flex flex-wrap items-center gap-2 rounded-md border border-surface-200 bg-darker px-3 py-2"
>
<div class="grow text-sm text-surface-content">
<p>{email.email}</p>
<p class="text-xs text-muted">{email.source}</p>
</div>
{#if email.can_unlink}
<form method="post" action={paths.unlink_email_path}>
<input type="hidden" name="_method" value="delete" />
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<input type="hidden" name="email" value={email.email} />
<Button
type="submit"
variant="surface"
size="xs"
class="rounded-md"
>
Unlink
</Button>
</form>
{/if}
</div>
{/each}
{:else}
<p
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
> >
No email addresses are linked. <div class="grow text-sm text-surface-content">
</p> <p>{email.email}</p>
{/if} <p class="text-xs text-muted">{email.source}</p>
</div> </div>
{#if email.can_unlink}
<form method="post" action={paths.unlink_email_path}>
<input type="hidden" name="_method" value="delete" />
<input
type="hidden"
name="authenticity_token"
value={csrfToken}
/>
<input type="hidden" name="email" value={email.email} />
<Button
type="submit"
variant="surface"
size="xs"
class="rounded-md"
>
Unlink
</Button>
</form>
{/if}
</div>
{/each}
{:else}
<p
class="rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-muted"
>
No email addresses are linked.
</p>
{/if}
</div>
<form <form
method="post" id="integrations-email-form"
action={paths.add_email_path} method="post"
class="mt-4 flex flex-col gap-3 sm:flex-row" action={paths.add_email_path}
> class="mt-4 flex flex-col gap-3 sm:flex-row"
<input type="hidden" name="authenticity_token" value={csrfToken} /> >
<input <input type="hidden" name="authenticity_token" value={csrfToken} />
type="email" <input
name="email" type="email"
required name="email"
placeholder="name@example.com" required
class="grow rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none" placeholder="name@example.com"
/> class="grow rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
<Button type="submit" class="rounded-md">Add email</Button> />
</form> </form>
</section>
</div> {#snippet footer()}
<Button type="submit" class="rounded-md" form="integrations-email-form">
Add email
</Button>
{/snippet}
</SectionCard>
</SettingsShell> </SettingsShell>
<Modal <Modal

View file

@ -2,6 +2,7 @@
import { Checkbox } from "bits-ui"; import { Checkbox } from "bits-ui";
import { onMount } from "svelte"; import { onMount } from "svelte";
import Button from "../../../components/Button.svelte"; import Button from "../../../components/Button.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte"; import SettingsShell from "./Shell.svelte";
import type { NotificationsPageProps } from "./types"; import type { NotificationsPageProps } from "./types";
@ -14,11 +15,14 @@
settings_update_path, settings_update_path,
user, user,
errors, errors,
admin_tools,
}: NotificationsPageProps = $props(); }: NotificationsPageProps = $props();
let csrfToken = $state(""); let csrfToken = $state("");
let weeklySummaryEmailEnabled = $state(user.weekly_summary_email_enabled); let weeklySummaryEmailEnabled = $state(false);
$effect(() => {
weeklySummaryEmailEnabled = user.weekly_summary_email_enabled;
});
onMount(() => { onMount(() => {
csrfToken = csrfToken =
@ -35,48 +39,50 @@
{heading} {heading}
{subheading} {subheading}
{errors} {errors}
{admin_tools}
> >
<div class="space-y-8"> <SectionCard
<section id="user_email_notifications"> id="user_email_notifications"
<h2 class="text-xl font-semibold text-surface-content"> title="Email Notifications"
Email Notifications description="Control which product emails Hackatime sends to your linked email addresses."
</h2> >
<p class="mt-1 text-sm text-muted"> <form
Control which product emails Hackatime sends to your linked email id="notifications-settings-form"
addresses. method="post"
</p> action={settings_update_path}
class="space-y-4"
>
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<form method="post" action={settings_update_path} class="mt-4 space-y-4"> <div id="user_weekly_summary_email">
<input type="hidden" name="_method" value="patch" /> <label class="flex items-center gap-3 text-sm text-surface-content">
<input type="hidden" name="authenticity_token" value={csrfToken} /> <input
type="hidden"
name="user[weekly_summary_email_enabled]"
value="0"
/>
<Checkbox.Root
bind:checked={weeklySummaryEmailEnabled}
name="user[weekly_summary_email_enabled]"
value="1"
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
>
{#snippet children({ checked })}
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
{/snippet}
</Checkbox.Root>
Weekly coding summary email (sent Fridays at 5:30 PM GMT)
</label>
<p class="mt-2 text-xs text-muted">
Includes your weekly coding time, top projects, and top languages.
</p>
</div>
</form>
<div id="user_weekly_summary_email"> {#snippet footer()}
<label class="flex items-center gap-3 text-sm text-surface-content"> <Button type="submit" form="notifications-settings-form">
<input Save notification settings
type="hidden" </Button>
name="user[weekly_summary_email_enabled]" {/snippet}
value="0" </SectionCard>
/>
<Checkbox.Root
bind:checked={weeklySummaryEmailEnabled}
name="user[weekly_summary_email_enabled]"
value="1"
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
>
{#snippet children({ checked })}
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
{/snippet}
</Checkbox.Root>
Weekly coding summary email (sent Fridays at 5:30 PM GMT)
</label>
<p class="mt-2 text-xs text-muted">
Includes your weekly coding time, top projects, and top languages.
</p>
</div>
<Button type="submit">Save notification settings</Button>
</form>
</section>
</div>
</SettingsShell> </SettingsShell>

View file

@ -3,6 +3,7 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import Button from "../../../components/Button.svelte"; import Button from "../../../components/Button.svelte";
import Select from "../../../components/Select.svelte"; import Select from "../../../components/Select.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte"; import SettingsShell from "./Shell.svelte";
import type { ProfilePageProps } from "./types"; import type { ProfilePageProps } from "./types";
@ -18,7 +19,6 @@
options, options,
badges, badges,
errors, errors,
admin_tools,
}: ProfilePageProps = $props(); }: ProfilePageProps = $props();
let csrfToken = $state(""); let csrfToken = $state("");
@ -45,213 +45,238 @@
{heading} {heading}
{subheading} {subheading}
{errors} {errors}
{admin_tools}
> >
<div class="space-y-8"> <SectionCard
<section id="user_region"> id="user_region"
<h2 class="text-xl font-semibold text-surface-content"> title="Region and Timezone"
Region and Timezone description="Use your local region and timezone for accurate dashboards and leaderboards."
</h2> >
<p class="mt-1 text-sm text-muted"> <form
Use your local region and timezone for accurate dashboards and id="profile-region-form"
leaderboards. method="post"
action={settings_update_path}
class="space-y-4"
>
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label
for="country_code"
class="mb-2 block text-sm text-surface-content"
>
Country
</label>
<Select
id="country_code"
name="user[country_code]"
value={user.country_code || ""}
items={[
{ value: "", label: "Select a country" },
...options.countries,
]}
/>
</div>
<div id="user_timezone">
<label for="timezone" class="mb-2 block text-sm text-surface-content">
Timezone
</label>
<Select
id="timezone"
name="user[timezone]"
value={user.timezone}
items={options.timezones}
/>
</div>
</form>
{#snippet footer()}
<Button type="submit" variant="primary" form="profile-region-form">
Save region settings
</Button>
{/snippet}
</SectionCard>
<SectionCard
id="user_username"
title="Username"
description="This username is used in links and public profile pages."
>
<form
id="profile-username-form"
method="post"
action={settings_update_path}
class="space-y-3"
>
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div>
<label for="username" class="mb-2 block text-sm text-surface-content">
Username
</label>
<input
id="username"
name="user[username]"
value={user.username || ""}
maxlength={username_max_length}
placeholder="your-name"
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
{#if errors.username.length > 0}
<p class="mt-2 text-xs text-red">{errors.username[0]}</p>
{/if}
</div>
</form>
{#if badges.profile_url}
<p class="text-sm text-muted">
Public profile:
<a
href={badges.profile_url}
target="_blank"
class="text-primary underline"
>
{badges.profile_url}
</a>
</p> </p>
<form method="post" action={settings_update_path} class="mt-4 space-y-4"> {/if}
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div> {#snippet footer()}
<label <Button type="submit" variant="primary" form="profile-username-form">
for="country_code" Save username
class="mb-2 block text-sm text-surface-content" </Button>
> {/snippet}
Country </SectionCard>
</label>
<Select
id="country_code"
name="user[country_code]"
value={user.country_code || ""}
items={[
{ value: "", label: "Select a country" },
...options.countries,
]}
/>
</div>
<div id="user_timezone"> <SectionCard
<label for="timezone" class="mb-2 block text-sm text-surface-content"> id="user_privacy"
Timezone title="Privacy"
</label> description="Control whether your coding stats can be used by public APIs."
<Select >
id="timezone" <form
name="user[timezone]" id="profile-privacy-form"
value={user.timezone} method="post"
items={options.timezones} action={settings_update_path}
/> class="space-y-3"
</div> >
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<Button type="submit" variant="primary">Save region settings</Button> <label class="flex items-center gap-3 text-sm text-surface-content">
</form> <input type="hidden" name="user[allow_public_stats_lookup]" value="0" />
</section> <Checkbox.Root
bind:checked={allowPublicStatsLookup}
name="user[allow_public_stats_lookup]"
value="1"
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
>
{#snippet children({ checked })}
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
{/snippet}
</Checkbox.Root>
Allow public stats lookup
</label>
</form>
<section id="user_username"> {#snippet footer()}
<h2 class="text-xl font-semibold text-surface-content">Username</h2> <Button type="submit" variant="primary" form="profile-privacy-form">
<p class="mt-1 text-sm text-muted"> Save privacy settings
This username is used in links and public profile pages. </Button>
</p> {/snippet}
<form method="post" action={settings_update_path} class="mt-4 space-y-3"> </SectionCard>
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<div> <SectionCard
<label for="username" class="mb-2 block text-sm text-surface-content"> id="user_theme"
Username title="Theme"
</label> description="Pick how Hackatime looks for your account."
<input wide
id="username" >
name="user[username]" <form
value={user.username || ""} id="profile-theme-form"
maxlength={username_max_length} method="post"
placeholder="your-name" action={settings_update_path}
class="w-full rounded-md border border-surface-200 bg-darker px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none" class="space-y-4"
/> >
{#if errors.username.length > 0} <input type="hidden" name="_method" value="patch" />
<p class="mt-2 text-xs text-red">{errors.username[0]}</p> <input type="hidden" name="authenticity_token" value={csrfToken} />
{/if}
</div>
<Button type="submit" variant="primary">Save username</Button> <RadioGroup.Root
</form> name="user[theme]"
bind:value={selectedTheme}
{#if badges.profile_url} class="grid grid-cols-1 gap-4 md:grid-cols-2"
<p class="mt-3 text-sm text-muted"> >
Public profile: {#each options.themes as theme}
<a <RadioGroup.Item
href={badges.profile_url} value={theme.value}
target="_blank" class="block cursor-pointer rounded-xl border p-4 text-left outline-none transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-surface-100 data-[state=unchecked]:border-surface-200 data-[state=unchecked]:bg-darker/40 data-[state=unchecked]:hover:border-surface-300"
class="text-primary underline"
>
{badges.profile_url}
</a>
</p>
{/if}
</section>
<section id="user_privacy">
<h2 class="text-xl font-semibold text-surface-content">Privacy</h2>
<p class="mt-1 text-sm text-muted">
Control whether your coding stats can be used by public APIs.
</p>
<form method="post" action={settings_update_path} class="mt-4 space-y-3">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<label class="flex items-center gap-3 text-sm text-surface-content">
<input
type="hidden"
name="user[allow_public_stats_lookup]"
value="0"
/>
<Checkbox.Root
bind:checked={allowPublicStatsLookup}
name="user[allow_public_stats_lookup]"
value="1"
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
> >
{#snippet children({ checked })} {#snippet children({ checked })}
<span class={checked ? "text-[10px]" : "hidden"}>✓</span> <div class="flex items-start justify-between gap-3">
{/snippet} <div>
</Checkbox.Root> <p class="text-sm font-semibold text-surface-content">
Allow public stats lookup {theme.label}
</label> </p>
<p class="mt-1 text-xs text-muted">{theme.description}</p>
<Button type="submit" variant="primary">Save privacy settings</Button>
</form>
</section>
<section id="user_theme">
<h2 class="text-xl font-semibold text-surface-content">Theme</h2>
<p class="mt-1 text-sm text-muted">
Pick how Hackatime looks for your account.
</p>
<form method="post" action={settings_update_path} class="mt-4 space-y-4">
<input type="hidden" name="_method" value="patch" />
<input type="hidden" name="authenticity_token" value={csrfToken} />
<RadioGroup.Root
name="user[theme]"
bind:value={selectedTheme}
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
{#each options.themes as theme}
<RadioGroup.Item
value={theme.value}
class="block cursor-pointer rounded-xl border p-4 text-left outline-none transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-surface-100 data-[state=unchecked]:border-surface-200 data-[state=unchecked]:bg-darker/40 data-[state=unchecked]:hover:border-surface-300"
>
{#snippet children({ checked })}
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-surface-content">
{theme.label}
</p>
<p class="mt-1 text-xs text-muted">{theme.description}</p>
</div>
{#if checked}
<span
class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
Selected
</span>
{/if}
</div> </div>
{#if checked}
<span
class="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary"
>
Selected
</span>
{/if}
</div>
<div
class="mt-3 rounded-lg border p-2"
style={`background:${theme.preview.darker};border-color:${theme.preview.darkless};color:${theme.preview.content};`}
>
<div <div
class="mt-3 rounded-lg border p-2" class="flex items-center justify-between rounded-md px-2 py-1"
style={`background:${theme.preview.darker};border-color:${theme.preview.darkless};color:${theme.preview.content};`} style={`background:${theme.preview.dark};`}
> >
<div <span class="text-[11px] font-semibold">Dashboard</span>
class="flex items-center justify-between rounded-md px-2 py-1" <span class="text-[10px] opacity-80">2h 14m</span>
style={`background:${theme.preview.dark};`}
>
<span class="text-[11px] font-semibold">Dashboard</span>
<span class="text-[10px] opacity-80">2h 14m</span>
</div>
<div
class="mt-2 grid grid-cols-[1fr_auto] items-center gap-2"
>
<span
class="h-2 rounded"
style={`background:${theme.preview.primary};`}
></span>
<span
class="h-2 w-8 rounded"
style={`background:${theme.preview.darkless};`}
></span>
</div>
<div class="mt-2 flex gap-1.5">
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.info};`}
></span>
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.success};`}
></span>
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.warning};`}
></span>
</div>
</div> </div>
{/snippet}
</RadioGroup.Item>
{/each}
</RadioGroup.Root>
<Button type="submit" variant="primary">Save theme</Button> <div class="mt-2 grid grid-cols-[1fr_auto] items-center gap-2">
</form> <span
</section> class="h-2 rounded"
</div> style={`background:${theme.preview.primary};`}
></span>
<span
class="h-2 w-8 rounded"
style={`background:${theme.preview.darkless};`}
></span>
</div>
<div class="mt-2 flex gap-1.5">
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.info};`}
></span>
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.success};`}
></span>
<span
class="h-1.5 w-6 rounded"
style={`background:${theme.preview.warning};`}
></span>
</div>
</div>
{/snippet}
</RadioGroup.Item>
{/each}
</RadioGroup.Root>
</form>
{#snippet footer()}
<Button type="submit" variant="primary" form="profile-theme-form">
Save theme
</Button>
{/snippet}
</SectionCard>
</SettingsShell> </SettingsShell>

View file

@ -2,7 +2,8 @@
import { Link } from "@inertiajs/svelte"; import { Link } from "@inertiajs/svelte";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { buildSections, sectionFromHash } from "./types"; import SubsectionNav from "./components/SubsectionNav.svelte";
import { buildSections, buildSubsections, sectionFromHash } from "./types";
import type { SectionPaths, SettingsCommonProps } from "./types"; import type { SectionPaths, SettingsCommonProps } from "./types";
let { let {
@ -12,20 +13,20 @@
heading, heading,
subheading, subheading,
errors, errors,
admin_tools,
children, children,
}: SettingsCommonProps & { children?: Snippet } = $props(); }: SettingsCommonProps & { children?: Snippet } = $props();
const sections = $derived(buildSections(section_paths, admin_tools.visible)); const sections = $derived(buildSections(section_paths));
const subsections = $derived(buildSubsections(active_section));
const knownSectionIds = $derived( const knownSectionIds = $derived(
new Set(sections.map((section) => section.id)), new Set(sections.map((section) => section.id)),
); );
const sectionButtonClass = (sectionId: keyof SectionPaths) => const sectionButtonClass = (sectionId: keyof SectionPaths) =>
`block w-full px-4 py-4 text-left transition-colors ${ `group block w-full rounded-xl border px-3 py-3 text-left transition-colors ${
active_section === sectionId active_section === sectionId
? "bg-surface-100 text-surface-content" ? "border-surface-300 bg-surface-100 text-surface-content shadow-[0_1px_0_rgba(255,255,255,0.02)]"
: "bg-surface text-muted hover:bg-surface-100 hover:text-surface-content" : "border-transparent bg-transparent text-muted hover:border-surface-200 hover:bg-surface-100/60 hover:text-surface-content"
}`; }`;
onMount(() => { onMount(() => {
@ -49,10 +50,12 @@
<title>{page_title}</title> <title>{page_title}</title>
</svelte:head> </svelte:head>
<div class="mx-auto max-w-7xl"> <div data-settings-shell class="mx-auto max-w-7xl">
<header class="mb-8"> <header class="mb-8">
<h1 class="text-3xl font-bold text-surface-content">{heading}</h1> <h1 class="text-3xl font-bold tracking-tight text-surface-content">
<p class="mt-2 text-sm text-muted">{subheading}</p> {heading}
</h1>
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted">{subheading}</p>
</header> </header>
{#if errors.full_messages.length > 0} {#if errors.full_messages.length > 0}
@ -68,22 +71,48 @@
</div> </div>
{/if} {/if}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[260px_minmax(0,1fr)]"> <nav
<aside class="h-max lg:sticky lg:top-8"> data-settings-mobile-nav
class="-mx-5 mb-6 overflow-x-auto px-5 lg:hidden"
>
<div class="flex min-w-full gap-2 pb-1">
{#each sections as section}
<Link
href={section.path}
class={`inline-flex shrink-0 items-center rounded-full border px-3 py-2 text-sm font-medium transition-colors ${
active_section === section.id
? "border-surface-300 bg-surface-100 text-surface-content"
: "border-surface-200 bg-surface/70 text-muted hover:border-surface-300 hover:text-surface-content"
}`}
>
{section.label}
</Link>
{/each}
</div>
</nav>
<div
class="grid grid-cols-1 gap-6 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-8"
>
<aside class="hidden h-max lg:sticky lg:top-8 lg:block">
<div <div
class="overflow-hidden rounded-xl border border-surface-200 bg-surface divide-y divide-surface-200" data-settings-sidebar
class="rounded-2xl border border-surface-200 bg-surface/90 p-2 shadow-[0_1px_0_rgba(255,255,255,0.02)]"
> >
{#each sections as section} {#each sections as section}
<Link href={section.path} class={sectionButtonClass(section.id)}> <Link href={section.path} class={sectionButtonClass(section.id)}>
<p class="text-sm font-semibold">{section.label}</p> <p class="text-sm font-semibold">{section.label}</p>
<p class="mt-1 text-xs opacity-80">{section.blurb}</p> <p class="mt-1 text-xs leading-5 opacity-80">{section.blurb}</p>
</Link> </Link>
{/each} {/each}
</div> </div>
</aside> </aside>
<section class="rounded-xl border border-surface-200 bg-surface p-5 md:p-6"> <div data-settings-content class="min-w-0 space-y-5">
{@render children?.()} <SubsectionNav items={subsections} />
</section> <div class="space-y-5">
{@render children?.()}
</div>
</div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,70 @@
<script lang="ts">
import type { Snippet } from "svelte";
type Tone = "default" | "danger";
let {
id,
title,
description,
tone = "default",
wide = false,
hasBody = true,
footerClass = "flex items-center justify-end gap-3",
children,
footer,
}: {
id?: string;
title: string;
description: string;
tone?: Tone;
wide?: boolean;
hasBody?: boolean;
footerClass?: string;
children?: Snippet;
footer?: Snippet;
} = $props();
const toneClasses = $derived(
tone === "danger"
? "border-danger/35 bg-danger/5"
: "border-surface-200 bg-surface",
);
const contentWidth = $derived(wide ? "" : "max-w-2xl");
const descriptionWidth = $derived(wide ? "max-w-3xl" : "max-w-2xl");
</script>
<section
{id}
data-settings-card
data-settings-card-tone={tone}
class={`scroll-mt-24 overflow-hidden rounded-2xl border ${toneClasses}`}
>
<div class="border-b border-surface-200 px-5 py-4 sm:px-6 sm:py-5">
<div class={descriptionWidth}>
<h2 class="text-xl font-semibold tracking-tight text-surface-content">
{title}
</h2>
<p class="mt-1 text-sm leading-6 text-muted">{description}</p>
</div>
</div>
{#if hasBody}
<div class="px-5 py-4 sm:px-6 sm:py-5">
<div class={contentWidth}>
{@render children?.()}
</div>
</div>
{/if}
{#if footer}
<div
data-settings-footer
class="border-t border-surface-200 bg-surface-100/60 px-5 py-3.5 sm:px-6 sm:py-4"
>
<div class={`${contentWidth} ${footerClass}`}>
{@render footer()}
</div>
</div>
{/if}
</section>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import { onMount } from "svelte";
import type { SettingsSubsection } from "../types";
let { items = [] }: { items?: SettingsSubsection[] } = $props();
let activeHash = $state("");
const normalizedHash = (value: string) => value.replace(/^#/, "");
const updateActiveHash = () => {
if (typeof window === "undefined") return;
activeHash = normalizedHash(window.location.hash);
};
const scrollToItem = (event: MouseEvent, id: string) => {
if (typeof window === "undefined" || typeof document === "undefined")
return;
const target = document.getElementById(id);
if (!target) return;
event.preventDefault();
activeHash = id;
target.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${id}`);
};
const isActive = (id: string) => {
if (!activeHash) return items[0]?.id === id;
return activeHash === id;
};
onMount(() => {
updateActiveHash();
window.addEventListener("hashchange", updateActiveHash);
return () => window.removeEventListener("hashchange", updateActiveHash);
});
</script>
{#if items.length > 0}
<nav
data-settings-subnav
aria-label="Settings subsections"
class="overflow-x-auto pb-1"
>
<div class="flex min-w-full items-center gap-2">
{#each items as item}
<a
href={`#${item.id}`}
data-settings-subnav-item
data-active={isActive(item.id)}
onclick={(event) => scrollToItem(event, item.id)}
class={`inline-flex shrink-0 items-center rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
isActive(item.id)
? "border-surface-300 bg-surface-100 text-surface-content"
: "border-surface-200 bg-surface/70 text-muted hover:border-surface-300 hover:text-surface-content"
}`}
>
{item.label}
</a>
{/each}
</div>
</nav>
{/if}

View file

@ -5,11 +5,22 @@ export type SectionId =
| "access" | "access"
| "goals" | "goals"
| "badges" | "badges"
| "data" | "data";
| "admin";
export type SectionPaths = Record<SectionId, string>; export type SectionPaths = Record<SectionId, string>;
export type SettingsSection = {
id: SectionId;
label: string;
blurb: string;
path: string;
};
export type SettingsSubsection = {
id: string;
label: string;
};
export type Option = { export type Option = {
label: string; label: string;
value: string; value: string;
@ -82,14 +93,10 @@ export type PathsProps = {
add_email_path: string; add_email_path: string;
unlink_email_path: string; unlink_email_path: string;
rotate_api_key_path: string; rotate_api_key_path: string;
migrate_heartbeats_path: string;
export_all_heartbeats_path: string; export_all_heartbeats_path: string;
export_range_heartbeats_path: string; export_range_heartbeats_path: string;
create_heartbeat_import_path: string; create_heartbeat_import_path: string;
create_deletion_path: string; create_deletion_path: string;
user_wakatime_mirrors_path: string;
heartbeat_import_source_path: string;
heartbeat_import_source_sync_path: string;
}; };
export type OptionsProps = { export type OptionsProps = {
@ -148,11 +155,6 @@ export type ConfigFileProps = {
api_url: string; api_url: string;
}; };
export type MigrationProps = {
enabled: boolean;
jobs: { id: string; status: string }[];
};
export type DataExportProps = { export type DataExportProps = {
total_heartbeats: string; total_heartbeats: string;
total_coding_time: string; total_coding_time: string;
@ -160,29 +162,15 @@ export type DataExportProps = {
is_restricted: boolean; is_restricted: boolean;
}; };
export type AdminToolsProps = {
visible: boolean;
mirrors: {
id: number;
endpoint_url: string;
enabled?: boolean;
last_synced_at?: string | null;
last_synced_ago: string;
consecutive_failures?: number;
last_error_message?: string | null;
last_error_at?: string | null;
destroy_path: string;
}[];
};
export type UiProps = { export type UiProps = {
show_dev_import: boolean; show_dev_import: boolean;
show_imports_and_mirrors: boolean; show_imports: boolean;
}; };
export type HeartbeatImportStatusProps = { export type HeartbeatImportStatusProps = {
import_id: string; import_id: string;
state: string; state: string;
source_kind: string;
progress_percent: number; progress_percent: number;
processed_count: number; processed_count: number;
total_count: number | null; total_count: number | null;
@ -190,31 +178,14 @@ export type HeartbeatImportStatusProps = {
skipped_count: number | null; skipped_count: number | null;
errors_count: number; errors_count: number;
message: string; message: string;
error_message?: string | null;
remote_dump_status?: string | null;
remote_percent_complete?: number | null;
cooldown_until?: string | null;
source_filename?: string | null;
updated_at: string; updated_at: string;
started_at?: string; started_at?: string | null;
finished_at?: string; finished_at?: string | null;
};
export type HeartbeatImportProps = {
import_id?: string | null;
status?: HeartbeatImportStatusProps | null;
};
export type HeartbeatImportSourceProps = {
id: number;
provider: string;
endpoint_url: string;
sync_enabled: boolean;
status: string;
initial_backfill_start_date?: string | null;
initial_backfill_end_date?: string | null;
backfill_cursor_date?: string | null;
last_synced_at?: string | null;
last_synced_ago?: string | null;
last_error_message?: string | null;
last_error_at?: string | null;
consecutive_failures: number;
imported_count: number;
}; };
export type ErrorsProps = { export type ErrorsProps = {
@ -229,7 +200,6 @@ export type SettingsCommonProps = {
heading: string; heading: string;
subheading: string; subheading: string;
errors: ErrorsProps; errors: ErrorsProps;
admin_tools: AdminToolsProps;
}; };
export type ProfilePageProps = SettingsCommonProps & { export type ProfilePageProps = SettingsCommonProps & {
@ -278,76 +248,100 @@ export type BadgesPageProps = SettingsCommonProps & {
export type DataPageProps = SettingsCommonProps & { export type DataPageProps = SettingsCommonProps & {
user: UserProps; user: UserProps;
paths: PathsProps; paths: PathsProps;
migration: MigrationProps;
data_export: DataExportProps; data_export: DataExportProps;
import_source?: HeartbeatImportSourceProps | null; imports_enabled: boolean;
mirrors: AdminToolsProps["mirrors"]; remote_import_cooldown_until?: string | null;
latest_heartbeat_import?: HeartbeatImportStatusProps | null;
ui: UiProps; ui: UiProps;
heartbeat_import: HeartbeatImportProps;
}; };
export type AdminPageProps = SettingsCommonProps & { export const buildSections = (
admin_tools: AdminToolsProps; sectionPaths: SectionPaths,
paths: PathsProps; ): SettingsSection[] => [
{
id: "profile",
label: "Profile",
blurb: "Username, region, timezone, and privacy.",
path: sectionPaths.profile,
},
{
id: "integrations",
label: "Integrations",
blurb: "Slack status, GitHub link, and email sign-in addresses.",
path: sectionPaths.integrations,
},
{
id: "notifications",
label: "Notifications",
blurb: "Email notifications and weekly summary preferences.",
path: sectionPaths.notifications,
},
{
id: "access",
label: "Access",
blurb: "Time tracking setup, extension options, and API key access.",
path: sectionPaths.access,
},
{
id: "goals",
label: "Goals",
blurb: "Set daily, weekly, or monthly programming targets.",
path: sectionPaths.goals,
},
{
id: "badges",
label: "Badges",
blurb: "Shareable badges and profile snippets.",
path: sectionPaths.badges,
},
{
id: "data",
label: "Data",
blurb: "Exports, imports, and deletion controls.",
path: sectionPaths.data,
},
];
const subsectionMap: Record<SectionId, SettingsSubsection[]> = {
profile: [
{ id: "user_region", label: "Region" },
{ id: "user_username", label: "Username" },
{ id: "user_privacy", label: "Privacy" },
{ id: "user_theme", label: "Theme" },
],
integrations: [
{ id: "user_slack_status", label: "Slack status" },
{ id: "user_slack_notifications", label: "Slack channels" },
{ id: "user_github_account", label: "GitHub" },
{ id: "user_email_addresses", label: "Email addresses" },
],
notifications: [
{ id: "user_email_notifications", label: "Email notifications" },
],
access: [
{ id: "user_tracking_setup", label: "Setup" },
{ id: "user_hackatime_extension", label: "Extension display" },
{ id: "user_api_key", label: "API key" },
{ id: "user_config_file", label: "Config file" },
],
goals: [
{ id: "user_programming_goals", label: "Programming goals" },
],
badges: [
{ id: "user_stats_badges", label: "Stats badges" },
{ id: "user_markscribe", label: "Markscribe" },
{ id: "user_heatmap", label: "Heatmap" },
],
data: [
{ id: "user_imports", label: "Imports" },
{ id: "download_user_data", label: "Download data" },
{ id: "delete_account", label: "Account deletion" },
],
}; };
export const buildSections = (sectionPaths: SectionPaths, adminVisible: boolean) => { export const buildSubsections = (
const sections = [ activeSection: SectionId,
{ ): SettingsSubsection[] => subsectionMap[activeSection] || [];
id: "profile" as SectionId,
label: "Profile",
blurb: "Username, region, timezone, and privacy.",
path: sectionPaths.profile,
},
{
id: "integrations" as SectionId,
label: "Integrations",
blurb: "Slack status, GitHub link, and email sign-in addresses.",
path: sectionPaths.integrations,
},
{
id: "notifications" as SectionId,
label: "Notifications",
blurb: "Email notifications and weekly summary preferences.",
path: sectionPaths.notifications,
},
{
id: "access" as SectionId,
label: "Access",
blurb: "Time tracking setup, extension options, and API key access.",
path: sectionPaths.access,
},
{
id: "goals" as SectionId,
label: "Goals",
blurb: "Set daily, weekly, or monthly programming targets.",
path: sectionPaths.goals,
},
{
id: "badges" as SectionId,
label: "Badges",
blurb: "Shareable badges and profile snippets.",
path: sectionPaths.badges,
},
{
id: "data" as SectionId,
label: "Data",
blurb: "Exports, imports, mirrors, migration jobs, and deletion controls.",
path: sectionPaths.data,
},
];
if (adminVisible) {
sections.push({
id: "admin",
label: "Admin",
blurb: "Administrative controls.",
path: sectionPaths.admin,
});
}
return sections;
};
const hashSectionMap: Record<string, SectionId> = { const hashSectionMap: Record<string, SectionId> = {
user_region: "profile", user_region: "profile",
@ -355,6 +349,7 @@ const hashSectionMap: Record<string, SectionId> = {
user_username: "profile", user_username: "profile",
user_privacy: "profile", user_privacy: "profile",
user_theme: "profile", user_theme: "profile",
user_tracking_setup: "access",
user_hackatime_extension: "access", user_hackatime_extension: "access",
user_api_key: "access", user_api_key: "access",
user_config_file: "access", user_config_file: "access",
@ -368,11 +363,9 @@ const hashSectionMap: Record<string, SectionId> = {
user_stats_badges: "badges", user_stats_badges: "badges",
user_markscribe: "badges", user_markscribe: "badges",
user_heatmap: "badges", user_heatmap: "badges",
user_migration_assistant: "data", user_imports: "data",
wakatime_import_source: "data",
download_user_data: "data", download_user_data: "data",
delete_account: "data", delete_account: "data",
wakatime_mirror: "data",
}; };
export const sectionFromHash = (hash: string): SectionId | null => { export const sectionFromHash = (hash: string): SectionId | null => {

View file

@ -0,0 +1,122 @@
class HeartbeatImportDumpJob < ApplicationJob
queue_as :latency_10s
include GoodJob::ActiveJobExtensions::Concurrency
POLL_INTERVAL = 30.seconds
retry_on HeartbeatImportDumpClient::TransientError,
wait: ->(executions) { (executions**2).seconds + rand(1..4).seconds },
attempts: 8
good_job_control_concurrency_with(
key: -> { "heartbeat_import_dump_job_#{arguments.first}" },
total_limit: 1
)
def perform(import_run_id)
run = HeartbeatImportRun.includes(:user).find_by(id: import_run_id)
return unless run&.remote?
return if run.terminal?
unless Flipper.enabled?(:imports, run.user)
fail_run!(run, "Imports are no longer enabled for this user.")
return
end
client = HeartbeatImportDumpClient.new(source_kind: run.source_kind, api_key: run.encrypted_api_key)
if run.remote_dump_id.blank?
request_dump!(run, client)
return
end
sync_dump!(run, client)
rescue HeartbeatImportDumpClient::AuthenticationError => e
fail_run!(run, e.message)
rescue HeartbeatImportDumpClient::RequestError => e
fail_run!(run, e.message)
end
private
def request_dump!(run, client)
run.update!(
state: :requesting_dump,
started_at: run.started_at || Time.current,
message: "Requesting data dump...",
error_message: nil
)
dump = client.request_dump
run.update!(
state: :waiting_for_dump,
remote_dump_id: dump[:id],
remote_dump_status: dump[:status],
remote_percent_complete: dump[:percent_complete],
remote_requested_at: Time.current,
message: waiting_message(dump),
error_message: nil
)
enqueue_poll(run)
end
def sync_dump!(run, client)
dump = client.list_dumps.find { |item| item[:id] == run.remote_dump_id.to_s }
raise HeartbeatImportDumpClient::RequestError, "Data dump #{run.remote_dump_id} was not found." if dump.blank?
if dump[:has_failed] || dump[:is_stuck]
fail_run!(run, "The remote provider could not finish preparing the data dump.")
return
end
if dump[:is_processing] || dump[:download_url].blank?
run.update!(
state: :waiting_for_dump,
remote_dump_status: dump[:status],
remote_percent_complete: dump[:percent_complete],
message: waiting_message(dump),
error_message: nil
)
enqueue_poll(run)
return
end
run.update!(
state: :downloading_dump,
remote_dump_status: dump[:status],
remote_percent_complete: dump[:percent_complete].positive? ? dump[:percent_complete] : 100.0,
message: "Downloading data dump...",
error_message: nil
)
file_content = client.download_dump(dump[:download_url])
file_path = HeartbeatImportRunner.persist_remote_download(run:, file_content:)
HeartbeatImportJob.perform_later(run.id, file_path)
end
def enqueue_poll(run)
self.class.set(wait: POLL_INTERVAL).perform_later(run.id)
end
def waiting_message(dump)
status = dump[:status].presence || "Preparing data dump"
percent = dump[:percent_complete].to_f
return "#{status}..." unless percent.positive?
"#{status} (#{percent.round}%)"
end
def fail_run!(run, message)
run.update!(
state: :failed,
remote_dump_status: run.remote_dump_status.presence || "failed",
message: "Import failed: #{message}",
error_message: message,
finished_at: Time.current
)
run.clear_sensitive_fields!
end
end

View file

@ -1,7 +1,7 @@
class HeartbeatImportJob < ApplicationJob class HeartbeatImportJob < ApplicationJob
queue_as :default queue_as :default
def perform(user_id, import_id, file_path) def perform(import_run_id, file_path)
HeartbeatImportRunner.run_import(user_id: user_id, import_id: import_id, file_path: file_path) HeartbeatImportRunner.run_import(import_run_id:, file_path:)
end end
end end

View file

@ -1,11 +0,0 @@
class HeartbeatImportSourceSchedulerJob < ApplicationJob
queue_as :latency_5m
def perform
return unless Flipper.enabled?(:wakatime_imports_mirrors)
HeartbeatImportSource.where(sync_enabled: true).where.not(status: :paused).pluck(:id).each do |source_id|
HeartbeatImportSourceSyncJob.perform_later(source_id)
end
end
end

View file

@ -1,149 +0,0 @@
class HeartbeatImportSourceSyncDayJob < ApplicationJob
queue_as :latency_5m
include GoodJob::ActiveJobExtensions::Concurrency
retry_on WakatimeCompatibleClient::TransientError,
wait: ->(executions) { (executions**2).seconds + rand(1..4).seconds },
attempts: 8
good_job_control_concurrency_with(
key: -> { "heartbeat_import_source_sync_day_job_#{arguments.first}_#{arguments.second}" },
total_limit: 1
)
def perform(source_id, date_string)
return unless Flipper.enabled?(:wakatime_imports_mirrors)
source = HeartbeatImportSource.find_by(id: source_id)
return unless source&.sync_enabled?
date = Date.iso8601(date_string)
rows = source.client.fetch_heartbeats(date:)
upsert_heartbeats(source.user_id, rows)
source.update!(
last_synced_at: Time.current,
last_error_message: nil,
last_error_at: nil,
consecutive_failures: 0
)
rescue WakatimeCompatibleClient::AuthenticationError => e
source&.update!(
sync_enabled: false,
status: :paused,
last_error_message: e.message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: source.consecutive_failures.to_i + 1
)
rescue WakatimeCompatibleClient::TransientError => e
source&.update!(
status: source&.backfilling? ? :backfilling : :failed,
last_error_message: e.message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: source.consecutive_failures.to_i + 1
)
raise
rescue WakatimeCompatibleClient::RequestError => e
source&.update!(
status: :failed,
last_error_message: e.message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: source.consecutive_failures.to_i + 1
)
rescue ArgumentError => e
source&.update!(
status: :failed,
last_error_message: e.message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: source.consecutive_failures.to_i + 1
)
end
private
def upsert_heartbeats(user_id, rows)
normalized = rows.filter_map { |row| normalize_row(user_id, row) }
return if normalized.empty?
deduped_records = normalized.group_by { |record| record[:fields_hash] }.map do |_, records|
records.max_by { |record| record[:time].to_f }
end
Heartbeat.upsert_all(deduped_records, unique_by: [ :fields_hash ])
end
def normalize_row(user_id, row)
data = row.respond_to?(:with_indifferent_access) ? row.with_indifferent_access : row.to_h.with_indifferent_access
timestamp = extract_timestamp(data)
return nil if timestamp.blank?
attrs = {
user_id: user_id,
branch: value_or_nil(data[:branch]),
category: value_or_nil(data[:category]) || "coding",
dependencies: extract_dependencies(data[:dependencies]),
editor: value_or_nil(data[:editor]),
entity: value_or_nil(data[:entity]),
language: value_or_nil(data[:language]),
machine: value_or_nil(data[:machine]),
operating_system: value_or_nil(data[:operating_system]),
project: value_or_nil(data[:project]),
type: value_or_nil(data[:type]),
user_agent: value_or_nil(data[:user_agent]),
line_additions: data[:line_additions],
line_deletions: data[:line_deletions],
lineno: data[:lineno],
lines: data[:lines],
cursorpos: data[:cursorpos],
project_root_count: data[:project_root_count],
time: timestamp,
is_write: ActiveModel::Type::Boolean.new.cast(data[:is_write]),
source_type: :wakapi_import
}
now = Time.current
attrs[:created_at] = now
attrs[:updated_at] = now
attrs[:fields_hash] = Heartbeat.generate_fields_hash(attrs)
attrs
rescue TypeError, JSON::ParserError
nil
end
def extract_dependencies(value)
return value if value.is_a?(Array)
return [] if value.blank?
JSON.parse(value.to_s)
rescue JSON::ParserError
value.to_s.split(",").map(&:strip).reject(&:blank?)
end
def extract_timestamp(data)
value = data[:time]
value = data[:created_at] if value.blank?
return nil if value.blank?
if value.is_a?(Numeric)
normalized = value.to_f
return (normalized / 1000.0) if normalized > 1_000_000_000_000
return normalized
end
parsed = Time.parse(value.to_s).to_f
return parsed if parsed.positive?
nil
rescue ArgumentError
nil
end
def value_or_nil(value)
return nil if value.nil?
return value.strip.presence if value.is_a?(String)
value
end
end

View file

@ -1,131 +0,0 @@
class HeartbeatImportSourceSyncJob < ApplicationJob
queue_as :latency_5m
include GoodJob::ActiveJobExtensions::Concurrency
BACKFILL_WINDOW_DAYS = 5
retry_on WakatimeCompatibleClient::TransientError,
wait: ->(executions) { (executions**2).seconds + rand(1..4).seconds },
attempts: 8
good_job_control_concurrency_with(
key: -> { "heartbeat_import_source_sync_job_#{arguments.first}" },
total_limit: 1
)
def perform(source_id)
return unless Flipper.enabled?(:wakatime_imports_mirrors)
source = HeartbeatImportSource.find_by(id: source_id)
return unless source&.sync_enabled?
return if source.paused?
initialize_backfill_if_needed(source)
if source.backfilling?
schedule_backfill_window(source)
return
end
source.update!(status: :syncing)
enqueue_day_sync(source, Date.yesterday)
enqueue_day_sync(source, Date.current)
rescue WakatimeCompatibleClient::AuthenticationError => e
source&.update!(
sync_enabled: false,
status: :paused,
last_error_message: e.message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: source.consecutive_failures.to_i + 1
)
rescue WakatimeCompatibleClient::TransientError => e
source&.update!(
status: source&.backfilling? ? :backfilling : :failed,
last_error_message: e.message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: source.consecutive_failures.to_i + 1
)
raise
rescue WakatimeCompatibleClient::RequestError => e
source&.update!(
status: :failed,
last_error_message: e.message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: source.consecutive_failures.to_i + 1
)
end
private
def initialize_backfill_if_needed(source)
should_initialize = source.idle? ||
(source.failed? && source.backfill_cursor_date.blank? && source.last_synced_at.blank?)
return unless should_initialize
return unless source.backfill_cursor_date.blank?
start_date = source.initial_backfill_start_date
end_date = source.initial_backfill_end_date || Date.current
if start_date.blank?
begin
start_date = source.client.fetch_all_time_since_today_start_date
rescue => e
Rails.logger.error("Failed to fetch all_time_since_today for source #{source.id}: #{e.message}")
source.update!(status: :failed, last_error_message: e.message, last_error_at: Time.current)
return
end
end
if start_date > end_date
source.update!(
status: :syncing,
backfill_cursor_date: nil,
initial_backfill_start_date: start_date,
initial_backfill_end_date: end_date
)
return
end
source.update!(
status: :backfilling,
initial_backfill_start_date: start_date,
initial_backfill_end_date: end_date,
backfill_cursor_date: start_date,
last_error_message: nil,
last_error_at: nil,
consecutive_failures: 0
)
end
def schedule_backfill_window(source)
cursor = source.backfill_cursor_date
end_date = source.initial_backfill_end_date || Date.current
return if cursor.blank?
if cursor > end_date
source.update!(status: :syncing, backfill_cursor_date: nil)
self.class.perform_later(source.id)
return
end
window_end = [ cursor + (BACKFILL_WINDOW_DAYS - 1).days, end_date ].min
(cursor..window_end).each do |date|
enqueue_day_sync(source, date)
end
next_cursor = window_end + 1.day
if next_cursor > end_date
source.update!(status: :syncing, backfill_cursor_date: nil)
self.class.perform_later(source.id)
else
source.update!(status: :backfilling, backfill_cursor_date: next_cursor)
self.class.perform_later(source.id)
end
end
def enqueue_day_sync(source, date)
HeartbeatImportSourceSyncDayJob.perform_later(source.id, date.iso8601)
end
end

View file

@ -1,108 +0,0 @@
class MigrateUserFromHackatimeJob < ApplicationJob
queue_as :latency_5m
include GoodJob::ActiveJobExtensions::Concurrency
# only allow one instance of this job to run at a time
good_job_control_concurrency_with(
key: -> { "migrate_user_from_hackatime_job_#{arguments.first}" },
total_limit: 1,
)
def perform(user_id)
return unless Flipper.enabled?(:hackatime_v1_import)
@user = User.find(user_id)
# Import from Hackatime
return unless @user.slack_uid.present?
return unless Hackatime::User.exists?(id: @user.slack_uid)
import_api_keys
import_heartbeats
# We don't want to trigger notifications due to extra time from importing
# heartbeats, so reset
reset_sailors_log
end
private
def reset_sailors_log
return unless @user.sailors_log.present?
@user.sailors_log.update!(projects_summary: {})
@user.sailors_log.send(:initialize_projects_summary)
end
def import_heartbeats
# create Heartbeat records for each Hackatime::Heartbeat in batches of 1000 as upsert
Hackatime::Heartbeat.where(user_id: @user.slack_uid).find_in_batches do |batch|
records_to_upsert = batch.map do |heartbeat|
# Convert dependencies to proper array format or default to empty array
dependencies = if heartbeat.dependencies.is_a?(String)
# If it's a string, try to parse it as JSON, fallback to empty array
begin
JSON.parse(heartbeat.dependencies)
rescue JSON::ParserError
# If it can't be parsed as JSON, split by comma and clean up
heartbeat.dependencies.gsub(/[{}]/, "").split(",").map(&:strip)
end
else
heartbeat.dependencies.presence || []
end
attrs = {
user_id: @user.id,
time: heartbeat.time,
project: heartbeat.project,
branch: heartbeat.branch,
category: heartbeat.category,
dependencies: dependencies,
editor: heartbeat.editor,
entity: heartbeat.entity,
language: heartbeat.language,
machine: heartbeat.machine,
operating_system: heartbeat.operating_system,
type: heartbeat.type,
user_agent: heartbeat.user_agent,
line_additions: heartbeat.line_additions,
line_deletions: heartbeat.line_deletions,
lineno: heartbeat.line_number,
lines: heartbeat.lines,
cursorpos: heartbeat.cursor_position,
project_root_count: heartbeat.project_root_count,
is_write: heartbeat.is_write,
source_type: :wakapi_import
# raw_data: heartbeat.attributes.slice(*Heartbeat.indexed_attributes)
}
{
**attrs,
fields_hash: Heartbeat.generate_fields_hash(attrs)
}
end
# dedupe records by fields_hash
records_to_upsert = records_to_upsert.group_by { |r| r[:fields_hash] }.map do |_, records|
records.max_by { |r| r[:time] }
end
Heartbeat.upsert_all(records_to_upsert, unique_by: [ :fields_hash ])
end
end
def import_api_keys
puts "Importing API keys"
hackatime_user = Hackatime::User.find(@user.slack_uid)
return if hackatime_user.nil?
ApiKey.upsert(
{
user_id: @user.id,
name: "Imported from Hackatime",
token: hackatime_user.api_key
},
unique_by: [ :user_id, :name ]
)
end
end

View file

@ -1,24 +0,0 @@
class MirrorFanoutEnqueueJob < ApplicationJob
queue_as :latency_10s
DEBOUNCE_TTL = 10.seconds
def perform(user_id)
return unless Flipper.enabled?(:wakatime_imports_mirrors)
return if debounced?(user_id)
User.find_by(id: user_id)&.wakatime_mirrors&.active&.pluck(:id)&.each do |mirror_id|
WakatimeMirrorSyncJob.perform_later(mirror_id)
end
end
private
def debounced?(user_id)
key = "mirror_fanout_enqueue_job:user:#{user_id}"
return true if Rails.cache.read(key)
Rails.cache.write(key, true, expires_in: DEBOUNCE_TTL)
false
end
end

View file

@ -30,8 +30,7 @@ class SailorsLogPollForChangesJob < ApplicationJob
private private
def update_sailors_log(sailors_log) def update_sailors_log(sailors_log)
# Skip if there's an active migration job for this user return [] if sailors_log.user.active_remote_heartbeat_import_run?
return [] if sailors_log.user.in_progress_migration_jobs?
project_updates = [] project_updates = []
project_durations = Heartbeat.where(user_id: sailors_log.user.id) project_durations = Heartbeat.where(user_id: sailors_log.user.id)

View file

@ -1,86 +0,0 @@
class WakatimeMirrorSyncJob < ApplicationJob
queue_as :latency_10s
include GoodJob::ActiveJobExtensions::Concurrency
BATCH_SIZE = 25
MAX_BATCHES_PER_RUN = 20
class MirrorTransientError < StandardError; end
retry_on MirrorTransientError,
wait: ->(executions) { (executions**2).seconds + rand(1..4).seconds },
attempts: 8
retry_on HTTP::TimeoutError, HTTP::ConnectionError,
wait: ->(executions) { (executions**2).seconds + rand(1..4).seconds },
attempts: 8
good_job_control_concurrency_with(
key: -> { "wakatime_mirror_sync_job_#{arguments.first}" },
total_limit: 1
)
def perform(mirror_id)
return unless Flipper.enabled?(:wakatime_imports_mirrors)
mirror = WakatimeMirror.find_by(id: mirror_id)
return unless mirror&.enabled?
batches_processed = 0
cursor = mirror.last_synced_heartbeat_id.to_i
loop do
batch = mirror.direct_heartbeats_after(cursor).limit(BATCH_SIZE).to_a
break if batch.empty?
response = mirror.post_heartbeats(batch.map { |heartbeat| mirror_payload(heartbeat) })
status_code = response.status.to_i
if response.status.success?
cursor = batch.last.id
mirror.update!(
last_synced_heartbeat_id: cursor,
last_synced_at: Time.current,
consecutive_failures: 0,
last_error_message: nil,
last_error_at: nil
)
elsif [ 401, 403 ].include?(status_code)
mirror.mark_auth_failed!("Authentication failed (#{status_code}). Check your API key.")
return
elsif transient_status?(status_code)
mirror.record_transient_failure!("Mirror request failed with status #{status_code}.")
raise MirrorTransientError, "Mirror request failed with status #{status_code}"
else
mirror.mark_failed!("Mirror request failed with status #{status_code}.")
return
end
batches_processed += 1
break if batches_processed >= MAX_BATCHES_PER_RUN
end
if batches_processed >= MAX_BATCHES_PER_RUN &&
mirror.direct_heartbeats_after(cursor).exists?
self.class.perform_later(mirror.id)
end
rescue HTTP::TimeoutError, HTTP::ConnectionError => e
mirror&.record_transient_failure!("Mirror request failed: #{e.class.name}")
raise
end
private
def mirror_payload(heartbeat)
heartbeat.attributes.slice(*payload_attributes)
end
def payload_attributes
@payload_attributes ||= Heartbeat.indexed_attributes - [ "user_id" ]
end
def transient_status?(status_code)
status_code == 408 || status_code == 429 || status_code >= 500
end
end

View file

@ -1,17 +0,0 @@
class Hackatime::Heartbeat < HackatimeRecord
include Heartbeatable
def self.cached_recent_count
Rails.cache.fetch("heartbeats_recent_count", expires_in: 5.minutes) do
recent.size
end
end
scope :recent, -> { where("time > ?", 24.hours.ago) }
scope :today, -> { where("DATE(time) = ?", Date.current) }
# This is a hack to avoid using the default Rails inheritance column Rails is confused by the field `type` in the db
self.inheritance_column = nil
# Prevent collision with Ruby's hash method
self.ignored_columns += [ "hash" ]
end

View file

@ -1,14 +0,0 @@
class Hackatime::ProjectLabel < HackatimeRecord
self.table_name = "project_labels"
has_many :heartbeats,
->(project) { where(user_id: project.user_id) },
foreign_key: :project,
primary_key: :project_key,
class_name: "Hackatime::Heartbeat"
belongs_to :user,
foreign_key: :user_id,
primary_key: :slack_uid,
class_name: "User"
end

View file

@ -1,3 +0,0 @@
class Hackatime::User < HackatimeRecord
self.table_name = "users"
end

View file

@ -1,9 +0,0 @@
class HackatimeRecord < ApplicationRecord
self.abstract_class = true
begin
connects_to database: { reading: :wakatime, writing: :wakatime }
rescue StandardError => e
Rails.logger.warn "HackatimeRecord: Could not connect to wakatime database: #{e.message}"
end
end

View file

@ -0,0 +1,88 @@
class HeartbeatImportRun < ApplicationRecord
COOLDOWN = 4.hours
TERMINAL_STATES = %w[completed failed].freeze
ACTIVE_STATES = %w[queued requesting_dump waiting_for_dump downloading_dump importing].freeze
REMOTE_SOURCE_KINDS = %w[wakatime_dump hackatime_v1_dump].freeze
belongs_to :user
encrypts :encrypted_api_key, deterministic: false
enum :source_kind, {
dev_upload: 0,
wakatime_dump: 1,
hackatime_v1_dump: 2
}
enum :state, {
queued: 0,
requesting_dump: 1,
waiting_for_dump: 2,
downloading_dump: 3,
importing: 4,
completed: 5,
failed: 6
}
validates :encrypted_api_key, presence: true, on: :create, unless: :dev_upload?
scope :latest_first, -> { order(created_at: :desc) }
scope :active_imports, -> { where(state: states.values_at(*ACTIVE_STATES)) }
scope :remote_imports, -> { where(source_kind: source_kinds.values_at(*REMOTE_SOURCE_KINDS)) }
def remote?
!dev_upload?
end
def terminal?
TERMINAL_STATES.include?(state)
end
def active_import?
ACTIVE_STATES.include?(state)
end
def cooldown_until
return nil if remote_requested_at.blank?
remote_requested_at + COOLDOWN
end
def progress_percent
return 100 if completed?
if waiting_for_dump? || downloading_dump? || requesting_dump?
return remote_percent_complete.to_f.clamp(0, 100).round
end
return 0 unless total_count.to_i.positive?
((processed_count.to_f / total_count.to_f) * 100).clamp(0, 100).round
end
def clear_sensitive_fields!
update_columns(encrypted_api_key: nil, updated_at: Time.current)
end
def self.active_for(user)
where(user: user).active_imports.latest_first.first
end
def self.latest_for(user)
where(user: user).latest_first.first
end
def self.remote_cooldown_until_for(user)
latest_remote_request = where(user: user)
.remote_imports
.where.not(remote_requested_at: nil)
.order(remote_requested_at: :desc)
.pick(:remote_requested_at)
return nil if latest_remote_request.blank?
retry_at = latest_remote_request + COOLDOWN
retry_at.future? ? retry_at : nil
end
end

View file

@ -1,77 +0,0 @@
class HeartbeatImportSource < ApplicationRecord
require "uri"
belongs_to :user
encrypts :encrypted_api_key, deterministic: false
enum :provider, {
wakatime_compatible: 0
}
enum :status, {
idle: 0,
backfilling: 1,
syncing: 2,
paused: 3,
failed: 4
}
validates :provider, presence: true
validates :endpoint_url, presence: true
validates :encrypted_api_key, presence: true
validates :user_id, uniqueness: true
validate :validate_endpoint_url
validate :validate_backfill_range
before_validation :normalize_endpoint_url
def client
WakatimeCompatibleClient.new(endpoint_url:, api_key: encrypted_api_key)
end
def reset_backfill!
update!(
status: :idle,
backfill_cursor_date: nil,
last_synced_at: nil,
last_error_message: nil,
last_error_at: nil,
consecutive_failures: 0
)
end
private
def normalize_endpoint_url
self.endpoint_url = endpoint_url.to_s.strip.sub(%r{/*\z}, "")
end
def validate_backfill_range
return unless initial_backfill_start_date.present? && initial_backfill_end_date.present?
return unless initial_backfill_start_date > initial_backfill_end_date
errors.add(:initial_backfill_end_date, "must be on or after the start date")
end
def validate_endpoint_url
return if endpoint_url.blank?
uri = URI.parse(endpoint_url)
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
errors.add(:endpoint_url, "must be an HTTP or HTTPS URL")
return
end
if uri.host.blank?
errors.add(:endpoint_url, "must include a host")
return
end
if !Rails.env.development? && uri.scheme != "https"
errors.add(:endpoint_url, "must use https")
end
rescue URI::InvalidURIError
errors.add(:endpoint_url, "is invalid")
end
end

View file

@ -115,17 +115,6 @@ class User < ApplicationRecord
has_many :sign_in_tokens, dependent: :destroy has_many :sign_in_tokens, dependent: :destroy
has_many :project_repo_mappings has_many :project_repo_mappings
has_many :hackatime_heartbeats,
foreign_key: :user_id,
primary_key: :slack_uid,
class_name: "Hackatime::Heartbeat"
has_many :project_labels,
foreign_key: :user_id,
primary_key: :slack_uid,
class_name: "Hackatime::ProjectLabel"
has_many :api_keys has_many :api_keys
has_many :admin_api_keys, dependent: :destroy has_many :admin_api_keys, dependent: :destroy
has_many :oauth_applications, as: :owner, dependent: :destroy has_many :oauth_applications, as: :owner, dependent: :destroy
@ -135,8 +124,7 @@ class User < ApplicationRecord
primary_key: :slack_uid, primary_key: :slack_uid,
class_name: "SailorsLog" class_name: "SailorsLog"
has_many :wakatime_mirrors, dependent: :destroy has_many :heartbeat_import_runs, dependent: :destroy
has_one :heartbeat_import_source, dependent: :destroy
scope :search_identity, ->(term) { scope :search_identity, ->(term) {
term = term.to_s.strip.downcase term = term.to_s.strip.downcase
@ -225,19 +213,12 @@ class User < ApplicationRecord
after_save :invalidate_activity_graph_cache, if: :saved_change_to_timezone? after_save :invalidate_activity_graph_cache, if: :saved_change_to_timezone?
def data_migration_jobs def flipper_id
GoodJob::Job.where( "User;#{id}"
"serialized_params->>'arguments' = ?", [ id ].to_json
).where(
"job_class = ?", "MigrateUserFromHackatimeJob"
).order(created_at: :desc).limit(10).all
end end
def in_progress_migration_jobs? def active_remote_heartbeat_import_run?
GoodJob::Job.where(job_class: "MigrateUserFromHackatimeJob") heartbeat_import_runs.remote_imports.active_imports.exists?
.where("serialized_params->>'arguments' = ?", [ id ].to_json)
.where(finished_at: nil)
.exists?
end end
def format_extension_text(duration) def format_extension_text(duration)

View file

@ -1,111 +0,0 @@
class WakatimeMirror < ApplicationRecord
require "uri"
belongs_to :user
encrypts :encrypted_api_key, deterministic: false
attr_accessor :request_host
validates :endpoint_url, presence: true
validates :encrypted_api_key, presence: true
validates :endpoint_url, uniqueness: { scope: :user_id }
validate :validate_endpoint_url
before_validation :normalize_endpoint_url
before_create :initialize_last_synced_heartbeat_id
scope :active, -> { where(enabled: true) }
def direct_heartbeats_after(heartbeat_id)
user.heartbeats.where(source_type: :direct_entry).where("id > ?", heartbeat_id.to_i).order(id: :asc)
end
def post_heartbeats(payload)
HTTP.timeout(connect: 5, read: 30, write: 10)
.headers(
"Authorization" => "Basic #{Base64.strict_encode64(encrypted_api_key)}",
"Content-Type" => "application/json"
)
.post("#{endpoint_url}/users/current/heartbeats.bulk", json: payload)
end
def clear_error_state!
update!(
last_error_message: nil,
last_error_at: nil,
consecutive_failures: 0
)
end
def record_transient_failure!(message)
update!(
status_payload_for_failure(message, keep_enabled: true)
)
end
def mark_auth_failed!(message)
update!(
status_payload_for_failure(message, keep_enabled: false)
)
end
def mark_failed!(message)
update!(
status_payload_for_failure(message, keep_enabled: enabled)
)
end
private
def initialize_last_synced_heartbeat_id
self.last_synced_heartbeat_id ||= user.heartbeats.maximum(:id)
end
def normalize_endpoint_url
self.endpoint_url = endpoint_url.to_s.strip.sub(%r{/*\z}, "")
end
def validate_endpoint_url
return unless endpoint_url.present?
uri = URI.parse(endpoint_url)
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
errors.add(:endpoint_url, "must be an HTTP or HTTPS URL")
return
end
if uri.host.blank?
errors.add(:endpoint_url, "must include a host")
return
end
if !Rails.env.development? && uri.scheme != "https"
errors.add(:endpoint_url, "must use https")
return
end
if disallowed_hosts.include?(uri.host.downcase)
errors.add(:endpoint_url, "cannot target this Hackatime host")
end
rescue URI::InvalidURIError
errors.add(:endpoint_url, "is invalid")
end
def disallowed_hosts
hosts = %w[hackatime.hackclub.com www.hackatime.hackclub.com localhost 127.0.0.1]
hosts << request_host.to_s.downcase if request_host.present?
default_host = Rails.application.config.action_mailer.default_url_options&.dig(:host)
hosts << default_host.to_s.downcase if default_host.present?
hosts.uniq
end
def status_payload_for_failure(message, keep_enabled:)
{
enabled: keep_enabled,
last_error_message: message.to_s.truncate(500),
last_error_at: Time.current,
consecutive_failures: consecutive_failures.to_i + 1
}
end
end

View file

@ -64,7 +64,7 @@ class AnonymizeUserService
user.admin_api_keys.destroy_all user.admin_api_keys.destroy_all
user.sign_in_tokens.destroy_all user.sign_in_tokens.destroy_all
user.email_verification_requests.destroy_all user.email_verification_requests.destroy_all
user.wakatime_mirrors.destroy_all user.heartbeat_import_runs.destroy_all
user.project_repo_mappings.destroy_all user.project_repo_mappings.destroy_all
user.goals.destroy_all user.goals.destroy_all

View file

@ -0,0 +1,116 @@
class HeartbeatImportDumpClient
class AuthenticationError < StandardError; end
class TransientError < StandardError; end
class RequestError < StandardError; end
BASE_URLS = {
"wakatime_dump" => "https://wakatime.com/api/v1",
"hackatime_v1_dump" => "https://waka.hackclub.com/api/v1"
}.freeze
TIMEOUTS = {
connect: 5,
read: 60,
write: 15
}.freeze
def initialize(source_kind:, api_key:)
@source_kind = source_kind.to_s
@endpoint_url = BASE_URLS.fetch(@source_kind)
@api_key = api_key.to_s
end
def request_dump
body = request_json(
method: :post,
path: "/users/current/data_dumps",
json: { type: "heartbeats", email_when_finished: false }
)
normalize_dump(body.fetch("data"))
end
def list_dumps
body = request_json(method: :get, path: "/users/current/data_dumps")
Array(body["data"]).map { |dump| normalize_dump(dump) }
end
def download_dump(download_url)
response = HTTP.follow.timeout(TIMEOUTS).headers(download_headers(download_url)).get(download_url)
handle_response_errors(response)
response.to_s
rescue HTTP::TimeoutError, HTTP::ConnectionError => e
raise TransientError, e.message
end
def self.base_url_for(source_kind)
BASE_URLS.fetch(source_kind.to_s)
end
private
def request_json(method:, path:, json: nil)
request = HTTP.timeout(TIMEOUTS).headers(headers)
response = if json.nil?
request.public_send(method, "#{@endpoint_url}#{path}")
else
request.public_send(method, "#{@endpoint_url}#{path}", json:)
end
handle_response_errors(response)
JSON.parse(response.to_s)
rescue HTTP::TimeoutError, HTTP::ConnectionError => e
raise TransientError, e.message
rescue JSON::ParserError
raise RequestError, "Invalid JSON response"
end
def handle_response_errors(response)
status = response.status.to_i
raise AuthenticationError, "Authentication failed (#{status})" if [ 401, 403 ].include?(status)
raise TransientError, "Request failed with status #{status}" if status == 408 || status == 429 || status >= 500
raise RequestError, "Request failed with status #{status}" unless response.status.success?
end
def normalize_dump(dump)
payload = dump.respond_to?(:with_indifferent_access) ? dump.with_indifferent_access : dump.to_h.with_indifferent_access
{
id: payload[:id].to_s,
status: payload[:status].to_s,
percent_complete: payload[:percent_complete].to_f,
download_url: payload[:download_url].presence,
type: payload[:type].to_s,
is_processing: ActiveModel::Type::Boolean.new.cast(payload[:is_processing]),
is_stuck: ActiveModel::Type::Boolean.new.cast(payload[:is_stuck]),
has_failed: ActiveModel::Type::Boolean.new.cast(payload[:has_failed]),
expires: payload[:expires],
created_at: payload[:created_at]
}
end
def headers
{
"Authorization" => "Basic #{Base64.strict_encode64(basic_auth_credential)}",
"Accept" => "application/json",
"Content-Type" => "application/json"
}
end
def download_headers(download_url)
uri = URI.parse(download_url)
endpoint_uri = URI.parse(@endpoint_url)
if uri.host == endpoint_uri.host
headers.merge("Accept" => "application/json,application/octet-stream,*/*")
else
{ "Accept" => "application/json,application/octet-stream,*/*" }
end
rescue URI::InvalidURIError
{ "Accept" => "application/json,application/octet-stream,*/*" }
end
def basic_auth_credential
@source_kind == "wakatime_dump" ? "#{@api_key}:" : @api_key
end
end

View file

@ -1,134 +1,195 @@
require "fileutils" require "fileutils"
require "securerandom" require "stringio"
require "zlib"
class HeartbeatImportRunner class HeartbeatImportRunner
STATUS_TTL = 12.hours
PROGRESS_INTERVAL = 250 PROGRESS_INTERVAL = 250
REMOTE_REFRESH_THROTTLE = 5.seconds
TMP_DIR = Rails.root.join("tmp", "heartbeat_imports")
def self.start(user:, uploaded_file:) class ActiveImportError < StandardError; end
import_id = SecureRandom.uuid class CooldownError < StandardError
file_path = persist_uploaded_file(uploaded_file, import_id) attr_reader :retry_at
write_status(user_id: user.id, import_id: import_id, attributes: { def initialize(retry_at)
state: "queued", @retry_at = retry_at
progress_percent: 0, super("Remote imports are limited to once every 4 hours.")
processed_count: 0, end
total_count: nil, end
imported_count: nil,
skipped_count: nil, class FeatureDisabledError < StandardError; end
errors_count: 0, class InvalidProviderError < StandardError; end
def self.start_dev_upload(user:, uploaded_file:)
ensure_no_active_import!(user)
run = user.heartbeat_import_runs.create!(
source_kind: :dev_upload,
source_filename: uploaded_file.original_filename.to_s,
state: :queued,
message: "Queued import." message: "Queued import."
}) )
HeartbeatImportJob.perform_later(user.id, import_id, file_path) file_path = persist_uploaded_file(uploaded_file, run.id)
HeartbeatImportJob.perform_later(run.id, file_path)
import_id run
end end
def self.status(user:, import_id:) def self.start_remote_import(user:, provider:, api_key:)
Rails.cache.read(cache_key(user.id, import_id)) ensure_imports_enabled!(user)
ensure_no_active_import!(user)
ensure_remote_cooldown!(user)
run = user.heartbeat_import_runs.create!(
source_kind: normalize_provider(provider),
state: :queued,
encrypted_api_key: api_key.to_s,
message: "Queued import."
)
HeartbeatImportDumpJob.perform_later(run.id)
run
end end
def self.run_import(user_id:, import_id:, file_path:) def self.find_run(user:, import_id:)
user.heartbeat_import_runs.find_by(id: import_id)
end
def self.latest_run(user:)
HeartbeatImportRun.latest_for(user)
end
def self.refresh_remote_run!(run)
return run unless run&.remote?
return run unless run.active_import?
return run unless Flipper.enabled?(:imports, run.user)
return run if run.updated_at > REMOTE_REFRESH_THROTTLE.ago
if inline_good_job_execution?
HeartbeatImportDumpJob.perform_now(run.id)
else
HeartbeatImportDumpJob.perform_later(run.id)
end
run.reload
rescue => e
Rails.logger.error("Error refreshing heartbeat import run #{run&.id}: #{e.message}")
run
end
def self.remote_import_cooldown_until(user:)
HeartbeatImportRun.remote_cooldown_until_for(user)
end
def self.serialize(run)
return nil unless run
{
import_id: run.id.to_s,
state: run.state,
source_kind: run.source_kind,
progress_percent: run.progress_percent,
processed_count: run.processed_count,
total_count: run.total_count,
imported_count: run.imported_count,
skipped_count: run.skipped_count,
errors_count: run.errors_count,
message: run.message.to_s,
error_message: run.error_message,
remote_dump_status: run.remote_dump_status,
remote_percent_complete: run.remote_percent_complete,
cooldown_until: run.cooldown_until&.iso8601,
source_filename: run.source_filename,
updated_at: run.updated_at.iso8601,
started_at: run.started_at&.iso8601,
finished_at: run.finished_at&.iso8601
}
end
def self.run_import(import_run_id:, file_path:)
ActiveRecord::Base.connection_pool.with_connection do ActiveRecord::Base.connection_pool.with_connection do
user = User.find_by(id: user_id) run = HeartbeatImportRun.includes(:user).find_by(id: import_run_id)
unless user return unless run
write_status(user_id: user_id, import_id: import_id, attributes: { return if run.terminal?
state: "failed",
progress_percent: 0,
message: "User not found.",
finished_at: Time.current.iso8601
})
return
end
file_content = File.read(file_path).force_encoding("UTF-8") user = run.user
file_content = decode_file_content(File.binread(file_path)).force_encoding("UTF-8")
write_status(user_id: user_id, import_id: import_id, attributes: { run.update!(
state: "running", state: :importing,
total_count: nil, total_count: nil,
progress_percent: 0,
processed_count: 0, processed_count: 0,
started_at: Time.current.iso8601, started_at: run.started_at || Time.current,
message: "Importing heartbeats..." message: "Importing heartbeats..."
}) )
result = HeartbeatImportService.import_from_file( result = HeartbeatImportService.import_from_file(
file_content, file_content,
user, user,
progress_interval: PROGRESS_INTERVAL, progress_interval: PROGRESS_INTERVAL,
on_progress: lambda { |processed_count| on_progress: lambda { |processed_count|
write_status(user_id: user_id, import_id: import_id, attributes: { run.update_columns(
state: "running",
progress_percent: 0,
processed_count: processed_count, processed_count: processed_count,
total_count: nil, message: "Importing heartbeats...",
message: "Importing heartbeats..." updated_at: Time.current
}) )
} }
) )
if result[:success] if result[:success]
write_status(user_id: user_id, import_id: import_id, attributes: { run.update!(
state: "completed", state: :completed,
progress_percent: 100,
processed_count: result[:total_count], processed_count: result[:total_count],
total_count: result[:total_count], total_count: result[:total_count],
imported_count: result[:imported_count], imported_count: result[:imported_count],
skipped_count: result[:skipped_count], skipped_count: result[:skipped_count],
errors_count: result[:errors].length, errors_count: result[:errors].size,
message: build_success_message(result), message: build_success_message(result),
finished_at: Time.current.iso8601 error_message: nil,
}) finished_at: Time.current
)
reset_sailors_log!(user) if run.remote?
else else
write_status(user_id: user_id, import_id: import_id, attributes: { run.update!(
state: "failed", state: :failed,
progress_percent: 0,
imported_count: result[:imported_count], imported_count: result[:imported_count],
skipped_count: result[:skipped_count], skipped_count: result[:skipped_count],
errors_count: result[:errors].length, errors_count: result[:errors].size,
message: "Import failed: #{result[:error]}", message: "Import failed: #{result[:error]}",
finished_at: Time.current.iso8601 error_message: result[:error],
}) finished_at: Time.current
)
end end
end end
rescue => e rescue => e
write_status(user_id: user_id, import_id: import_id, attributes: { HeartbeatImportRun.find_by(id: import_run_id)&.update!(
state: "failed", state: :failed,
message: "Import failed: #{e.message}", message: "Import failed: #{e.message}",
finished_at: Time.current.iso8601 error_message: e.message,
}) finished_at: Time.current
)
ensure ensure
FileUtils.rm_f(file_path) if file_path.present? FileUtils.rm_f(file_path) if file_path.present?
ActiveRecord::Base.clear_active_connections! HeartbeatImportRun.find_by(id: import_run_id)&.clear_sensitive_fields!
ActiveRecord::Base.connection_handler.clear_active_connections!
end end
def self.write_status(user_id:, import_id:, attributes:) def self.persist_uploaded_file(uploaded_file, import_run_id)
key = cache_key(user_id, import_id) FileUtils.mkdir_p(TMP_DIR)
existing = Rails.cache.read(key) || {}
payload = existing.merge(attributes).merge(
import_id: import_id,
updated_at: Time.current.iso8601
)
Rails.cache.write(key, payload, expires_in: STATUS_TTL)
payload
end
def self.persist_uploaded_file(uploaded_file, import_id)
tmp_dir = Rails.root.join("tmp", "heartbeat_imports")
FileUtils.mkdir_p(tmp_dir)
ext = File.extname(uploaded_file.original_filename.to_s) ext = File.extname(uploaded_file.original_filename.to_s)
ext = ".json" if ext.blank? ext = ".json" if ext.blank?
file_path = tmp_dir.join("#{import_id}#{ext}") file_path = TMP_DIR.join("#{import_run_id}#{ext}")
FileUtils.cp(uploaded_file.tempfile.path, file_path) FileUtils.cp(uploaded_file.tempfile.path, file_path)
file_path.to_s file_path.to_s
end end
def self.cache_key(user_id, import_id) def self.persist_remote_download(run:, file_content:)
"heartbeat_import_status:user:#{user_id}:import:#{import_id}" FileUtils.mkdir_p(TMP_DIR)
file_path = TMP_DIR.join("#{run.id}-remote.json")
File.binwrite(file_path, decode_file_content(file_content))
file_path.to_s
end end
def self.build_success_message(result) def self.build_success_message(result)
@ -137,4 +198,56 @@ class HeartbeatImportRunner
"#{message} Skipped #{result[:skipped_count]} duplicate heartbeats." "#{message} Skipped #{result[:skipped_count]} duplicate heartbeats."
end end
def self.ensure_imports_enabled!(user)
return if Flipper.enabled?(:imports, user)
raise FeatureDisabledError, "Imports are not enabled for this user."
end
def self.ensure_no_active_import!(user)
return unless HeartbeatImportRun.active_for(user)
raise ActiveImportError, "Another import is already in progress."
end
def self.ensure_remote_cooldown!(user)
retry_at = remote_import_cooldown_until(user:)
return if retry_at.blank?
raise CooldownError, retry_at
end
def self.normalize_provider(provider)
normalized_provider = provider.to_s
aliases = {
"wakatime" => "wakatime_dump",
"hackatime_v1" => "hackatime_v1_dump",
"wakatime_dump" => "wakatime_dump",
"hackatime_v1_dump" => "hackatime_v1_dump"
}
aliases.fetch(normalized_provider) { raise InvalidProviderError, "Unsupported import provider." }
end
def self.decode_file_content(file_content)
return file_content unless file_content&.bytes&.first(2) == [ 0x1f, 0x8b ]
gz_reader = Zlib::GzipReader.new(StringIO.new(file_content))
gz_reader.read
ensure
gz_reader&.close
end
def self.reset_sailors_log!(user)
return unless user.sailors_log.present?
user.sailors_log.update!(projects_summary: {})
user.sailors_log.send(:initialize_projects_summary)
end
def self.inline_good_job_execution?
Rails.env.development? && Rails.application.config.good_job.execution_mode == :inline
end
end end

View file

@ -2,13 +2,8 @@ class HeartbeatImportService
BATCH_SIZE = 50_000 BATCH_SIZE = 50_000
def self.import_from_file(file_content, user, on_progress: nil, progress_interval: 250) def self.import_from_file(file_content, user, on_progress: nil, progress_interval: 250)
unless Rails.env.development?
raise StandardError, "Not dev env, not running"
end
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
user_id = user.id user_id = user.id
indexed_attrs = Heartbeat.indexed_attributes
imported_count = 0 imported_count = 0
total_count = 0 total_count = 0
errors = [] errors = []
@ -31,9 +26,9 @@ class HeartbeatImportService
language: hb["language"], language: hb["language"],
editor: hb["editor"], editor: hb["editor"],
operating_system: hb["operating_system"], operating_system: hb["operating_system"],
machine: hb["machine"], machine: hb["machine"] || hb["machine_name_id"],
branch: hb["branch"], branch: hb["branch"],
user_agent: hb["user_agent"], user_agent: hb["user_agent"] || hb["user_agent_id"],
is_write: hb["is_write"] || false, is_write: hb["is_write"] || false,
line_additions: hb["line_additions"], line_additions: hb["line_additions"],
line_deletions: hb["line_deletions"], line_deletions: hb["line_deletions"],
@ -42,16 +37,13 @@ class HeartbeatImportService
cursorpos: hb["cursorpos"], cursorpos: hb["cursorpos"],
dependencies: hb["dependencies"] || [], dependencies: hb["dependencies"] || [],
project_root_count: hb["project_root_count"], project_root_count: hb["project_root_count"],
source_type: 1 source_type: Heartbeat.source_types.fetch("wakapi_import")
} }
string_attrs = attrs.transform_keys(&:to_s) attrs[:fields_hash] = Heartbeat.generate_fields_hash(attrs)
hash_input = indexed_attrs.each_with_object({}) { |k, h| h[k] = string_attrs[k] }
fields_hash = Digest::MD5.hexdigest(Oj.dump(hash_input, mode: :compat))
attrs[:fields_hash] = fields_hash
existing = seen_hashes[fields_hash] existing = seen_hashes[attrs[:fields_hash]]
seen_hashes[fields_hash] = attrs if existing.nil? || attrs[:time] > existing[:time] seen_hashes[attrs[:fields_hash]] = attrs if existing.nil? || attrs[:time] > existing[:time]
if seen_hashes.size >= BATCH_SIZE if seen_hashes.size >= BATCH_SIZE
imported_count += flush_batch(seen_hashes) imported_count += flush_batch(seen_hashes)
@ -66,7 +58,7 @@ class HeartbeatImportService
on_progress&.call(total_count) on_progress&.call(total_count)
if total_count.zero? if total_count.zero?
raise StandardError, "Not correct format, download from /my/settings on the offical hackatime then import here" raise StandardError, "Expected a heartbeat export JSON file."
end end
imported_count += flush_batch(seen_hashes) if seen_hashes.any? imported_count += flush_batch(seen_hashes) if seen_hashes.any?
@ -122,15 +114,14 @@ class HeartbeatImportService
class HeartbeatSaxHandler < Oj::Saj class HeartbeatSaxHandler < Oj::Saj
def initialize(&block) def initialize(&block)
@block = block @block = block
@in_heartbeats = false
@depth = 0 @depth = 0
@current_heartbeat = nil @current_heartbeat = nil
@current_key = nil @heartbeat_array_depths = []
@array_depth = 0 @field_array_stack = []
end end
def hash_start(key) def hash_start(key)
if @in_heartbeats && @depth == 2 if inside_heartbeat_array? && @depth == @heartbeat_array_depths.last + 1
@current_heartbeat = {} @current_heartbeat = {}
end end
@depth += 1 @depth += 1
@ -138,44 +129,43 @@ class HeartbeatImportService
def hash_end(key) def hash_end(key)
@depth -= 1 @depth -= 1
if @in_heartbeats && @depth == 2 && @current_heartbeat if inside_heartbeat_array? && @depth == @heartbeat_array_depths.last + 1 && @current_heartbeat
@block.call(@current_heartbeat) @block.call(@current_heartbeat)
@current_heartbeat = nil @current_heartbeat = nil
end end
end end
def array_start(key) def array_start(key)
if key == "heartbeats" && @depth == 1 @heartbeat_array_depths << @depth if key == "heartbeats"
@in_heartbeats = true
elsif @current_heartbeat && @current_key if @current_heartbeat && key.present?
@current_heartbeat[@current_key] = [] @current_heartbeat[key] = []
@array_depth += 1 @field_array_stack << key
end end
@depth += 1 @depth += 1
end end
def array_end(key) def array_end(key)
@depth -= 1 @depth -= 1
if key == "heartbeats" @heartbeat_array_depths.pop if key == "heartbeats" && @heartbeat_array_depths.last == @depth
@in_heartbeats = false @field_array_stack.pop if @field_array_stack.last == key
elsif @array_depth > 0
@array_depth -= 1
end
end end
def add_value(value, key) def add_value(value, key)
return unless @current_heartbeat return unless @current_heartbeat
if key if key
@current_key = key @current_heartbeat[key] = value
if @array_depth > 0 && @current_heartbeat[@current_key].is_a?(Array) elsif @field_array_stack.any?
@current_heartbeat[@current_key] << value @current_heartbeat[@field_array_stack.last] << value
else
@current_heartbeat[key] = value
end
elsif @array_depth > 0 && @current_key && @current_heartbeat[@current_key].is_a?(Array)
@current_heartbeat[@current_key] << value
end end
end end
private
def inside_heartbeat_array?
@heartbeat_array_depths.any?
end
end end
end end

View file

@ -1,63 +0,0 @@
class WakatimeCompatibleClient
class AuthenticationError < StandardError; end
class TransientError < StandardError; end
class RequestError < StandardError; end
def initialize(endpoint_url:, api_key:)
@endpoint_url = endpoint_url.to_s.sub(%r{/*\z}, "")
@api_key = api_key.to_s
end
def fetch_all_time_since_today_start_date
body = get_json("/users/current/all_time_since_today")
start_date = body.dig("data", "range", "start_date") ||
body.dig("data", "start_date") ||
body.dig("range", "start_date")
raise RequestError, "Missing start_date in all_time_since_today response" if start_date.blank?
Date.iso8601(start_date.to_s)
rescue ArgumentError
raise RequestError, "Invalid start_date in all_time_since_today response"
end
def fetch_heartbeats(date:)
body = get_json("/users/current/heartbeats", params: { date: date.iso8601 })
if body.is_a?(Array)
body
elsif body["data"].is_a?(Array)
body["data"]
elsif body["heartbeats"].is_a?(Array)
body["heartbeats"]
else
[]
end
end
private
def get_json(path, params: nil)
response = HTTP.timeout(connect: 5, read: 30, write: 10)
.headers(headers)
.get("#{@endpoint_url}#{path}", params:)
status = response.status.to_i
raise AuthenticationError, "Authentication failed (#{status})" if [ 401, 403 ].include?(status)
raise TransientError, "Request failed with status #{status}" if status == 408 || status == 429 || status >= 500
raise RequestError, "Request failed with status #{status}" unless response.status.success?
JSON.parse(response.to_s)
rescue HTTP::TimeoutError, HTTP::ConnectionError => e
raise TransientError, e.message
rescue JSON::ParserError
raise RequestError, "Invalid JSON response"
end
def headers
{
"Authorization" => "Basic #{Base64.strict_encode64("#{@api_key}:")}",
"Content-Type" => "application/json",
"Accept" => "application/json"
}
end
end

View file

@ -188,39 +188,39 @@
"@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="], "@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.7", "", {}, "sha512-NOtJF9LQnV7k6bpzcXwL/rXdlFHvAT9e0imrftiMc6/+FUNBHRZ8UngDrM+jciA6ENzFYNoFs8rfwumuGF+Dhw=="], "@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@ -238,17 +238,17 @@
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bits-ui": ["bits-ui@2.15.5", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-WhS+P+E//ClLfKU6KqjKC17nGDRLnz+vkwoP6ClFUPd5m1fFVDxTElPX8QVsduLj5V1KFDxlnv6sW2G5Lqk+vw=="], "bits-ui": ["bits-ui@2.16.3", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
@ -330,7 +330,7 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], "devalue": ["devalue@5.6.4", "", {}, "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
@ -508,7 +508,7 @@
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg=="], "prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
@ -544,9 +544,9 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.51.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-AqApqNOxVS97V4Ko9UHTHeSuDJrwauJhZpLDs1gYD8Jk48ntCSWD7NxKje+fnGn5Ja1O3u2FzQZHPdifQjXe3w=="], "svelte": ["svelte@5.53.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.3", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-GYmqRjRhJYLQBonfdfGAt28gkfWEShrtXKGXcFGneXi502aBE+I1dJcs/YQriByvP6xqXRz/OdBGC6tfvUQHyQ=="],
"svelte-check": ["svelte-check@4.4.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="], "svelte-check": ["svelte-check@4.4.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="], "svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
@ -554,7 +554,7 @@
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="], "tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
@ -576,7 +576,7 @@
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite-plugin-ruby": ["vite-plugin-ruby@5.1.2", "", { "dependencies": { "debug": "^4.3.4", "fast-glob": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-aIhAhq9BOVhfWGYuludgYK9ch58SQClpDVyF8WfpNsr5xr6dCw24mgpFso2KsXYONGYzX2kDt+cca54nDzkitg=="], "vite-plugin-ruby": ["vite-plugin-ruby@5.1.3", "", { "dependencies": { "debug": "^4.3.4", "fast-glob": "^3.3.2" }, "peerDependencies": { "vite": ">=5.0.0" } }, "sha512-vpTOKbR6AmnupmXvQccRcr/jIY+oobNuCbNJHUzkT8oQ2BBQSlp22Xec3zszBBc7GjwDGn2+E+IQoNRXdEY7Ig=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
@ -584,10 +584,12 @@
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@inertiajs/svelte/@inertiajs/core": ["@inertiajs/core@file:vendor/inertia/packages/core", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "@types/lodash-es": "^4.17.12", "laravel-precognition": "2.0.0-beta.0", "lodash-es": "^4.17.23" }, "peerDependencies": { "axios": "^1.13.2" }, "optionalPeers": ["axios"] }], "@inertiajs/svelte/@inertiajs/core": ["@inertiajs/core@file:vendor/inertia/packages/core", {}],
"@layerstack/tailwind/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "@layerstack/tailwind/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@ -620,6 +622,28 @@
"@layerstack/tailwind/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], "@layerstack/tailwind/tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],

View file

@ -15,11 +15,6 @@ development:
encoding: unicode encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
url: <%= ENV['DATABASE_URL'] %> url: <%= ENV['DATABASE_URL'] %>
wakatime:
adapter: postgresql
encoding: unicode
url: <%= ENV['WAKATIME_DATABASE_URL'] %>
replica: true
sailors_log: sailors_log:
adapter: postgresql adapter: postgresql
encoding: unicode encoding: unicode
@ -35,11 +30,6 @@ test:
adapter: postgresql adapter: postgresql
database: app_test database: app_test
url: <%= ENV['TEST_DATABASE_URL'] %> url: <%= ENV['TEST_DATABASE_URL'] %>
wakatime:
adapter: postgresql
database: app_test
url: <%= ENV['TEST_DATABASE_URL'] %>
replica: true
sailors_log: sailors_log:
adapter: postgresql adapter: postgresql
database: app_test database: app_test
@ -55,11 +45,6 @@ production:
encoding: unicode encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 16 }.to_i + 8 %> # Web threads + 8 GoodJob threads pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 16 }.to_i + 8 %> # Web threads + 8 GoodJob threads
url: <%= ENV['POOL_DATABASE_URL'] %> url: <%= ENV['POOL_DATABASE_URL'] %>
wakatime:
adapter: postgresql
encoding: unicode
url: <%= ENV['WAKATIME_DATABASE_URL'] %>
replica: true
sailors_log: sailors_log:
adapter: postgresql adapter: postgresql
encoding: unicode encoding: unicode

View file

@ -110,10 +110,6 @@ Rails.application.configure do
class: "Cache::HeartbeatCountsJob", class: "Cache::HeartbeatCountsJob",
kwargs: { force_reload: true } kwargs: { force_reload: true }
}, },
heartbeat_import_source_scheduler: {
cron: "*/5 * * * *",
class: "HeartbeatImportSourceSchedulerJob"
},
weekly_summary_email: { weekly_summary_email: {
cron: "30 17 * * 5", cron: "30 17 * * 5",
class: "WeeklySummaryEmailJob", class: "WeeklySummaryEmailJob",

View file

@ -134,8 +134,6 @@ Rails.application.routes.draw do
member do member do
patch :update_trust_level patch :update_trust_level
end end
resource :wakatime_mirrors, only: [ :create ]
resources :wakatime_mirrors, only: [ :destroy ]
end end
get "my/projects", to: "my/project_repo_mappings#index", as: :my_projects get "my/projects", to: "my/project_repo_mappings#index", as: :my_projects
@ -157,17 +155,9 @@ Rails.application.routes.draw do
delete "my/settings/goals/:goal_id", to: "settings/goals#destroy", as: :my_settings_goal_destroy delete "my/settings/goals/:goal_id", to: "settings/goals#destroy", as: :my_settings_goal_destroy
get "my/settings/badges", to: "settings/badges#show", as: :my_settings_badges get "my/settings/badges", to: "settings/badges#show", as: :my_settings_badges
get "my/settings/data", to: "settings/data#show", as: :my_settings_data get "my/settings/data", to: "settings/data#show", as: :my_settings_data
get "my/settings/admin", to: "settings/admin#show", as: :my_settings_admin
post "my/settings/migrate_heartbeats", to: "settings/data#migrate_heartbeats", as: :my_settings_migrate_heartbeats
post "my/settings/rotate_api_key", to: "settings/access#rotate_api_key", as: :my_settings_rotate_api_key post "my/settings/rotate_api_key", to: "settings/access#rotate_api_key", as: :my_settings_rotate_api_key
namespace :my do namespace :my do
resource :heartbeat_import_source,
only: [ :create, :update, :show, :destroy ],
controller: "heartbeat_import_sources" do
post :sync, on: :collection, action: :sync_now
end
resources :heartbeat_imports, only: [ :create, :show ] resources :heartbeat_imports, only: [ :create, :show ]
resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do resources :project_repo_mappings, param: :project_name, only: [ :edit, :update ], constraints: { project_name: /.+/ } do
@ -181,7 +171,6 @@ Rails.application.routes.draw do
resources :heartbeats, only: [] do resources :heartbeats, only: [] do
collection do collection do
post :export post :export
post :import
end end
end end
end end

View file

@ -0,0 +1,29 @@
class CreateHeartbeatImportRuns < ActiveRecord::Migration[8.1]
def change
create_table :heartbeat_import_runs do |t|
t.references :user, null: false, foreign_key: true
t.integer :source_kind, null: false
t.integer :state, null: false, default: 0
t.string :source_filename
t.string :encrypted_api_key
t.string :remote_dump_id
t.string :remote_dump_status
t.float :remote_percent_complete
t.integer :processed_count, null: false, default: 0
t.integer :total_count
t.integer :imported_count
t.integer :skipped_count
t.integer :errors_count, null: false, default: 0
t.text :message
t.text :error_message
t.datetime :started_at
t.datetime :finished_at
t.datetime :remote_requested_at
t.timestamps
end
add_index :heartbeat_import_runs, [ :user_id, :created_at ]
add_index :heartbeat_import_runs, [ :user_id, :state ]
end
end

29
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_11_170528) do ActiveRecord::Schema[8.1].define(version: 2026_03_12_134424) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
@ -234,6 +234,32 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_11_170528) do
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end end
create_table "heartbeat_import_runs", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "encrypted_api_key"
t.text "error_message"
t.integer "errors_count", default: 0, null: false
t.datetime "finished_at"
t.integer "imported_count"
t.text "message"
t.integer "processed_count", default: 0, null: false
t.string "remote_dump_id"
t.string "remote_dump_status"
t.float "remote_percent_complete"
t.datetime "remote_requested_at"
t.integer "skipped_count"
t.string "source_filename"
t.integer "source_kind", null: false
t.datetime "started_at"
t.integer "state", default: 0, null: false
t.integer "total_count"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["user_id", "created_at"], name: "index_heartbeat_import_runs_on_user_id_and_created_at"
t.index ["user_id", "state"], name: "index_heartbeat_import_runs_on_user_id_and_state"
t.index ["user_id"], name: "index_heartbeat_import_runs_on_user_id"
end
create_table "heartbeat_import_sources", force: :cascade do |t| create_table "heartbeat_import_sources", force: :cascade do |t|
t.date "backfill_cursor_date" t.date "backfill_cursor_date"
t.integer "consecutive_failures", default: 0, null: false t.integer "consecutive_failures", default: 0, null: false
@ -654,6 +680,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_11_170528) do
add_foreign_key "email_addresses", "users" add_foreign_key "email_addresses", "users"
add_foreign_key "email_verification_requests", "users" add_foreign_key "email_verification_requests", "users"
add_foreign_key "goals", "users" add_foreign_key "goals", "users"
add_foreign_key "heartbeat_import_runs", "users"
add_foreign_key "heartbeat_import_sources", "users" add_foreign_key "heartbeat_import_sources", "users"
add_foreign_key "heartbeats", "raw_heartbeat_uploads" add_foreign_key "heartbeats", "raw_heartbeat_uploads"
add_foreign_key "heartbeats", "users" add_foreign_key "heartbeats", "users"

View file

@ -14,23 +14,23 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11", "@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.2.1",
"@tsconfig/svelte": "^5.0.7", "@tsconfig/svelte": "^5.0.8",
"axios": "^1.13.2", "axios": "^1.13.6",
"bits-ui": "^2.15.5", "bits-ui": "^2.16.3",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"layerchart": "^1.0.13", "layerchart": "^1.0.13",
"plur": "^6.0.0", "plur": "^6.0.0",
"svelte": "^5.51.2", "svelte": "^5.53.11",
"svelte-check": "^4.4.0", "svelte-check": "^4.4.5",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.2.1",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-ruby": "^5.1.2" "vite-plugin-ruby": "^5.1.3"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.8.1", "prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1" "prettier-plugin-svelte": "^3.5.1"
} }
} }

View file

@ -98,33 +98,34 @@ RSpec.describe 'Api::V1::My', type: :request do
end end
end end
path '/my/heartbeats/import' do path '/my/heartbeat_imports' do
post('Import Heartbeats') do post('Create Heartbeat Import') do
tags 'My Data' tags 'My Data'
description 'Import heartbeats from a JSON file.' description 'Start a development upload import or a one-time remote dump import.'
security [ Bearer: [], ApiKeyAuth: [] ] security [ Bearer: [], ApiKeyAuth: [] ]
consumes 'multipart/form-data' consumes 'multipart/form-data'
produces 'application/json' produces 'application/json'
parameter name: :heartbeat_file, parameter name: :"heartbeat_import[provider]",
in: :formData, in: :formData,
schema: { type: :string, format: :binary }, schema: { type: :string, enum: %w[wakatime_dump hackatime_v1_dump] },
description: 'JSON file containing heartbeats' description: 'Remote import provider preset'
parameter name: :"heartbeat_import[api_key]",
in: :formData,
schema: { type: :string },
description: 'API key for the selected remote import provider'
response(302, 'redirect') do response(202, 'accepted') do
let(:Authorization) { "Bearer dev-api-key-12345" } let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' } let(:api_key) { 'dev-api-key-12345' }
let(:heartbeat_file) do let(:"heartbeat_import[provider]") { "wakatime_dump" }
Rack::Test::UploadedFile.new( let(:"heartbeat_import[api_key]") { "test-api-key" }
StringIO.new("[]"),
"application/json",
original_filename: "heartbeats.json"
)
end
before do before do
login_browser_user login_browser_user
Flipper.enable_actor(:imports, user)
end end
after { Flipper.disable(:imports) }
run_test! run_test!
end end
end end
@ -251,29 +252,29 @@ RSpec.describe 'Api::V1::My', type: :request do
end end
end end
path '/my/settings/migrate_heartbeats' do path '/my/heartbeat_imports/{id}' do
post('Migrate Heartbeats') do get('Get Heartbeat Import Status') do
tags 'My Settings' tags 'My Data'
description 'Trigger a migration of heartbeats from legacy formats or systems.' description 'Fetch the latest state for a heartbeat import run.'
security [ Bearer: [], ApiKeyAuth: [] ] security [ Bearer: [], ApiKeyAuth: [] ]
produces 'application/json' produces 'application/json'
response(302, 'redirect') do parameter name: :id, in: :path, type: :string, description: 'Heartbeat import run id'
response(200, 'successful') do
let(:Authorization) { "Bearer dev-api-key-12345" } let(:Authorization) { "Bearer dev-api-key-12345" }
let(:api_key) { 'dev-api-key-12345' } let(:api_key) { 'dev-api-key-12345' }
let(:id) do
HeartbeatImportRun.create!(
user: user,
source_kind: :dev_upload,
state: :completed,
source_filename: "heartbeats.json",
message: "Completed."
).id
end
before do before { login_browser_user }
login_browser_user
@hackatime_v1_import_was_enabled = Flipper.enabled?(:hackatime_v1_import)
Flipper.enable(:hackatime_v1_import)
end
after do
if @hackatime_v1_import_was_enabled
Flipper.enable(:hackatime_v1_import)
else
Flipper.disable(:hackatime_v1_import)
end
end
run_test! run_test!
end end
end end

View file

@ -2591,26 +2591,28 @@ paths:
responses: responses:
'302': '302':
description: redirect description: redirect
"/my/heartbeats/import": "/my/heartbeat_imports":
post: post:
summary: Import Heartbeats summary: Create Heartbeat Import
tags: tags:
- My Data - My Data
description: Import heartbeats from a JSON file. description: Start a development upload import or a one-time remote dump import.
security: security:
- Bearer: [] - Bearer: []
ApiKeyAuth: [] ApiKeyAuth: []
parameters: [] parameters: []
responses: responses:
'302': '202':
description: redirect description: accepted
requestBody: requestBody:
content: content:
multipart/form-data: multipart/form-data:
schema: schema:
type: string type: string
format: binary enum:
description: JSON file containing heartbeats - wakatime_dump
- hackatime_v1_dump
description: Remote import provider preset
"/my/projects": "/my/projects":
get: get:
summary: List Project Repo Mappings summary: List Project Repo Mappings
@ -2723,18 +2725,25 @@ paths:
responses: responses:
'200': '200':
description: successful description: successful
"/my/settings/migrate_heartbeats": "/my/heartbeat_imports/{id}":
post: get:
summary: Migrate Heartbeats summary: Get Heartbeat Import Status
tags: tags:
- My Settings - My Data
description: Trigger a migration of heartbeats from legacy formats or systems. description: Fetch the latest state for a heartbeat import run.
security: security:
- Bearer: [] - Bearer: []
ApiKeyAuth: [] ApiKeyAuth: []
parameters:
- name: id
in: path
description: Heartbeat import run id
required: true
schema:
type: string
responses: responses:
'302': '200':
description: redirect description: successful
"/deletion": "/deletion":
post: post:
summary: Create Deletion Request summary: Create Deletion Request

View file

@ -1,110 +0,0 @@
require "test_helper"
class My::HeartbeatImportSourcesControllerTest < ActionDispatch::IntegrationTest
setup do
Flipper.enable(:wakatime_imports_mirrors)
end
teardown do
Flipper.disable(:wakatime_imports_mirrors)
end
test "requires auth for create" do
post my_heartbeat_import_source_path, params: {
heartbeat_import_source: {
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "api-key"
}
}
assert_response :redirect
assert_redirected_to root_path
end
test "authenticated user can create source and queue sync" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
GoodJob::Job.where(job_class: "HeartbeatImportSourceSyncJob").delete_all
assert_difference -> { HeartbeatImportSource.count }, 1 do
assert_difference -> { GoodJob::Job.where(job_class: "HeartbeatImportSourceSyncJob").count }, 1 do
post my_heartbeat_import_source_path, params: {
heartbeat_import_source: {
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "api-key",
sync_enabled: "1"
}
}
end
end
assert_response :redirect
assert_redirected_to my_settings_data_path
end
test "show returns configured source payload" do
user = User.create!(timezone: "UTC")
source = user.create_heartbeat_import_source!(
provider: :wakatime_compatible,
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "api-key"
)
sign_in_as(user)
get my_heartbeat_import_source_path
assert_response :success
payload = JSON.parse(response.body)
assert_equal source.id, payload.dig("import_source", "id")
assert_equal "wakatime_compatible", payload.dig("import_source", "provider")
end
test "sync now queues source sync" do
user = User.create!(timezone: "UTC")
source = user.create_heartbeat_import_source!(
provider: :wakatime_compatible,
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "api-key",
sync_enabled: true
)
sign_in_as(user)
GoodJob::Job.where(job_class: "HeartbeatImportSourceSyncJob").delete_all
assert_difference -> { GoodJob::Job.where(job_class: "HeartbeatImportSourceSyncJob").count }, 1 do
post sync_my_heartbeat_import_source_path
end
assert_response :redirect
assert_redirected_to my_settings_data_path
assert_equal source.id, GoodJob::Job.where(job_class: "HeartbeatImportSourceSyncJob").last.serialized_params.dig("arguments", 0)
end
test "destroy removes source" do
user = User.create!(timezone: "UTC")
user.create_heartbeat_import_source!(
provider: :wakatime_compatible,
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "api-key"
)
sign_in_as(user)
assert_difference -> { HeartbeatImportSource.count }, -1 do
delete my_heartbeat_import_source_path
end
assert_response :redirect
assert_redirected_to my_settings_data_path
end
test "returns not found json when imports and mirrors are disabled" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
Flipper.disable(:wakatime_imports_mirrors)
get my_heartbeat_import_source_path, as: :json
assert_response :not_found
payload = JSON.parse(response.body)
assert_equal "Imports and mirrors are currently disabled.", payload["error"]
end
end

View file

@ -1,8 +1,22 @@
require "test_helper" require "test_helper"
class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
include ActiveJob::TestHelper
fixtures :users fixtures :users
setup do
@original_queue_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :test
clear_enqueued_jobs
clear_performed_jobs
end
teardown do
Flipper.disable(:imports)
ActiveJob::Base.queue_adapter = @original_queue_adapter
end
test "create rejects guests" do test "create rejects guests" do
post my_heartbeat_imports_path post my_heartbeat_imports_path
@ -10,39 +24,25 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
assert_equal "You must be logged in to view this page.", JSON.parse(response.body)["error"] assert_equal "You must be logged in to view this page.", JSON.parse(response.body)["error"]
end end
test "create rejects non-development environment" do test "create rejects dev upload outside development" do
user = users(:one)
sign_in_as(user)
post my_heartbeat_imports_path, params: { heartbeat_file: uploaded_file }
assert_redirected_with_import_error("Heartbeat import is only available in development.")
end
test "create returns error when no import data is provided" do
user = users(:one) user = users(:one)
sign_in_as(user) sign_in_as(user)
post my_heartbeat_imports_path post my_heartbeat_imports_path
assert_response :forbidden assert_redirected_with_import_error("No import data provided.")
assert_equal "Heartbeat import is only available in development.", JSON.parse(response.body)["error"]
end end
test "show rejects non-development environment" do test "create returns error when dev upload file type is invalid" do
user = users(:one)
sign_in_as(user)
get my_heartbeat_import_path("import-123")
assert_response :forbidden
assert_equal "Heartbeat import is only available in development.", JSON.parse(response.body)["error"]
end
test "create returns error when file is missing" do
user = users(:one)
sign_in_as(user)
with_development_env do
post my_heartbeat_imports_path
end
assert_response :unprocessable_entity
assert_equal "pls select a file to import", JSON.parse(response.body)["error"]
end
test "create returns error when file type is invalid" do
user = users(:one) user = users(:one)
sign_in_as(user) sign_in_as(user)
@ -52,58 +52,181 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
} }
end end
assert_response :unprocessable_entity assert_redirected_with_import_error("pls upload only json (download from the button above it)")
assert_equal "pls upload only json (download from the button above it)", JSON.parse(response.body)["error"]
end end
test "create starts import and returns status" do test "create starts dev upload import" do
user = users(:one) user = users(:one)
sign_in_as(user) sign_in_as(user)
with_development_env do with_development_env do
with_memory_cache do assert_difference -> { user.heartbeat_import_runs.count }, +1 do
post my_heartbeat_imports_path, params: { assert_enqueued_with(job: HeartbeatImportJob) do
heartbeat_file: uploaded_file post my_heartbeat_imports_path, params: { heartbeat_file: uploaded_file }
} end
end end
end end
assert_response :accepted run = user.heartbeat_import_runs.order(:created_at).last
body = JSON.parse(response.body)
assert body["import_id"].present? assert_redirected_to my_settings_data_url
assert_equal "queued", body.dig("status", "state") assert_equal "queued", run.state
assert_equal 0, body.dig("status", "progress_percent") assert_equal "dev_upload", run.source_kind
end
test "remote create rejects users without the imports feature" do
user = users(:one)
sign_in_as(user)
post my_heartbeat_imports_path, params: remote_params(provider: "wakatime_dump")
assert_redirected_with_import_error("Imports are not enabled for this user.")
end
test "remote create rejects during cooldown" do
user = users(:one)
sign_in_as(user)
Flipper.enable_actor(:imports, user)
user.heartbeat_import_runs.create!(
source_kind: :wakatime_dump,
state: :completed,
encrypted_api_key: "old-secret",
remote_requested_at: 1.hour.ago
)
post my_heartbeat_imports_path, params: remote_params(provider: "wakatime_dump")
assert_redirected_with_import_error("Remote imports are limited to once every 4 hours.")
assert flash[:cooldown_until].present?
end
test "remote create rejects when another import is active" do
user = users(:one)
sign_in_as(user)
Flipper.enable_actor(:imports, user)
user.heartbeat_import_runs.create!(
source_kind: :dev_upload,
state: :queued,
source_filename: "old.json"
)
post my_heartbeat_imports_path, params: remote_params(provider: "wakatime_dump")
assert_redirected_with_import_error("Another import is already in progress.")
end
test "remote create starts wakatime import" do
user = users(:one)
sign_in_as(user)
Flipper.enable_actor(:imports, user)
assert_difference -> { user.heartbeat_import_runs.count }, +1 do
assert_enqueued_with(job: HeartbeatImportDumpJob) do
post my_heartbeat_imports_path, params: remote_params(provider: "wakatime_dump")
end
end
run = user.heartbeat_import_runs.order(:created_at).last
assert_redirected_to my_settings_data_url
assert_equal "wakatime_dump", run.source_kind
assert_equal "queued", run.state
end
test "remote create starts hackatime v1 import" do
user = users(:one)
sign_in_as(user)
Flipper.enable_actor(:imports, user)
assert_difference -> { user.heartbeat_import_runs.count }, +1 do
assert_enqueued_with(job: HeartbeatImportDumpJob) do
post my_heartbeat_imports_path, params: remote_params(provider: "hackatime_v1_dump")
end
end
run = user.heartbeat_import_runs.order(:created_at).last
assert_redirected_to my_settings_data_url
assert_equal "hackatime_v1_dump", run.source_kind
assert_equal "queued", run.state
end end
test "show returns status for existing import" do test "show returns status for existing import" do
user = users(:one) user = users(:one)
sign_in_as(user) sign_in_as(user)
with_development_env do run = user.heartbeat_import_runs.create!(
with_memory_cache do source_kind: :dev_upload,
post my_heartbeat_imports_path, params: { heartbeat_file: uploaded_file } state: :completed,
import_id = JSON.parse(response.body).fetch("import_id") source_filename: "heartbeats.json",
imported_count: 4,
total_count: 5,
skipped_count: 1,
message: "Completed."
)
get my_heartbeat_import_path(import_id) get my_heartbeat_import_path(run)
end
assert_response :success
assert_equal run.id.to_s, JSON.parse(response.body).fetch("import_id")
end
test "show refreshes stale remote imports" do
user = users(:one)
sign_in_as(user)
Flipper.enable_actor(:imports, user)
run = user.heartbeat_import_runs.create!(
source_kind: :hackatime_v1_dump,
state: :waiting_for_dump,
encrypted_api_key: "secret",
remote_dump_id: "dump-123",
remote_requested_at: 10.minutes.ago,
remote_dump_status: "Pending…",
message: "Pending…..."
)
run.update_column(:updated_at, 10.seconds.ago)
singleton_class = HeartbeatImportRunner.singleton_class
singleton_class.alias_method :__original_refresh_remote_run_for_test, :refresh_remote_run!
singleton_class.define_method(:refresh_remote_run!) do |stale_run|
stale_run.update!(
remote_dump_status: "Completed",
message: "Downloading data dump..."
)
stale_run.reload
end
begin
get my_heartbeat_import_path(run)
ensure
singleton_class.alias_method :refresh_remote_run!, :__original_refresh_remote_run_for_test
singleton_class.remove_method :__original_refresh_remote_run_for_test
end end
assert_response :success assert_response :success
assert_equal "queued", JSON.parse(response.body).fetch("state") body = JSON.parse(response.body)
assert_equal "Completed", body["remote_dump_status"]
assert_equal "Downloading data dump...", body["message"]
end end
test "show returns not found for unknown import id" do test "show returns not found for another user's import" do
user = users(:one) user = users(:one)
other_user = users(:two)
sign_in_as(user) sign_in_as(user)
with_development_env do run = other_user.heartbeat_import_runs.create!(
with_memory_cache do source_kind: :dev_upload,
get my_heartbeat_import_path("missing-import") state: :queued,
end source_filename: "other.json"
end )
get my_heartbeat_import_path(run)
assert_response :not_found assert_response :not_found
assert_equal "Import not found", JSON.parse(response.body).fetch("error") assert_equal "Import not found", JSON.parse(response.body)["error"]
end end
private private
@ -119,14 +242,6 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
rails_singleton.remove_method :__original_env_for_test rails_singleton.remove_method :__original_env_for_test
end end
def with_memory_cache
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
yield
ensure
Rails.cache = original_cache
end
def uploaded_file(filename: "heartbeats.json", content_type: "application/json", content: '{"heartbeats":[]}') def uploaded_file(filename: "heartbeats.json", content_type: "application/json", content: '{"heartbeats":[]}')
Rack::Test::UploadedFile.new( Rack::Test::UploadedFile.new(
StringIO.new(content), StringIO.new(content),
@ -134,4 +249,18 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
original_filename: filename original_filename: filename
) )
end end
def remote_params(provider:)
{
heartbeat_import: {
provider: provider,
api_key: "remote-key-#{SecureRandom.hex(8)}"
}
}
end
def assert_redirected_with_import_error(message)
assert_redirected_to my_settings_data_url
assert_equal message, session[:inertia_errors]&.dig(:import)
end
end end

View file

@ -1,46 +0,0 @@
require "test_helper"
class WakatimeMirrorsControllerTest < ActionDispatch::IntegrationTest
setup do
Flipper.enable(:wakatime_imports_mirrors)
end
teardown do
Flipper.disable(:wakatime_imports_mirrors)
end
test "creates mirror when imports and mirrors are enabled" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
assert_difference -> { user.reload.wakatime_mirrors.count }, 1 do
post user_wakatime_mirrors_path(user), params: {
wakatime_mirror: {
endpoint_url: "https://wakapi.dev/api/compat/wakatime/v1",
encrypted_api_key: "mirror-key"
}
}
end
assert_response :redirect
assert_redirected_to my_settings_data_path
end
test "blocks mirror create when imports and mirrors are disabled" do
user = User.create!(timezone: "UTC")
sign_in_as(user)
Flipper.disable(:wakatime_imports_mirrors)
assert_no_difference -> { user.reload.wakatime_mirrors.count } do
post user_wakatime_mirrors_path(user), params: {
wakatime_mirror: {
endpoint_url: "https://wakapi.dev/api/compat/wakatime/v1",
encrypted_api_key: "mirror-key"
}
}
end
assert_response :redirect
assert_redirected_to my_settings_data_path
end
end

View file

@ -0,0 +1,130 @@
require "test_helper"
class HeartbeatImportDumpJobTest < ActiveJob::TestCase
include ActiveJob::TestHelper
setup do
@original_queue_adapter = ActiveJob::Base.queue_adapter
ActiveJob::Base.queue_adapter = :test
end
teardown do
Flipper.disable(:imports)
ActiveJob::Base.queue_adapter = @original_queue_adapter
clear_enqueued_jobs
clear_performed_jobs
end
test "requests a remote dump and schedules polling" do
user = User.create!(timezone: "UTC")
Flipper.enable_actor(:imports, user)
run = user.heartbeat_import_runs.create!(
source_kind: :wakatime_dump,
state: :queued,
encrypted_api_key: "secret"
)
fake_client = Object.new
fake_client.define_singleton_method(:request_dump) do
{
id: "dump-123",
status: "Pending",
percent_complete: 12.0,
download_url: nil,
type: "heartbeats",
is_processing: true,
is_stuck: false,
has_failed: false
}
end
with_dump_client(fake_client) do
assert_enqueued_with(job: HeartbeatImportDumpJob) do
HeartbeatImportDumpJob.perform_now(run.id)
end
end
run.reload
assert_equal "waiting_for_dump", run.state
assert_equal "dump-123", run.remote_dump_id
assert_not_nil run.remote_requested_at
end
test "downloads a completed dump and enqueues the import job" do
user = User.create!(timezone: "UTC")
Flipper.enable_actor(:imports, user)
run = user.heartbeat_import_runs.create!(
source_kind: :hackatime_v1_dump,
state: :waiting_for_dump,
encrypted_api_key: "secret",
remote_dump_id: "dump-456",
remote_requested_at: Time.current
)
fake_client = Object.new
fake_client.define_singleton_method(:list_dumps) do
[
{
id: "dump-456",
status: "Completed",
percent_complete: 100.0,
download_url: "https://example.invalid/download.json",
type: "heartbeats",
is_processing: false,
is_stuck: false,
has_failed: false
}
]
end
fake_client.define_singleton_method(:download_dump) do |_url|
'{"heartbeats":[]}'
end
with_dump_client(fake_client) do
assert_enqueued_with(job: HeartbeatImportJob) do
HeartbeatImportDumpJob.perform_now(run.id)
end
end
run.reload
assert_equal "downloading_dump", run.state
end
test "marks the run as failed on authentication errors" do
user = User.create!(timezone: "UTC")
Flipper.enable_actor(:imports, user)
run = user.heartbeat_import_runs.create!(
source_kind: :wakatime_dump,
state: :queued,
encrypted_api_key: "secret"
)
fake_client = Object.new
fake_client.define_singleton_method(:request_dump) do
raise HeartbeatImportDumpClient::AuthenticationError, "Authentication failed (401)"
end
with_dump_client(fake_client) do
HeartbeatImportDumpJob.perform_now(run.id)
end
run.reload
assert_equal "failed", run.state
assert_equal "Import failed: Authentication failed (401)", run.message
assert_nil run.encrypted_api_key
end
private
def with_dump_client(fake_client)
singleton_class = HeartbeatImportDumpClient.singleton_class
singleton_class.alias_method :__original_new_for_test, :new
singleton_class.define_method(:new) do |*|
fake_client
end
yield
ensure
singleton_class.alias_method :new, :__original_new_for_test
singleton_class.remove_method :__original_new_for_test
end
end

View file

@ -1,187 +0,0 @@
require "test_helper"
require "webmock/minitest"
class HeartbeatImportSourceSyncJobTest < ActiveJob::TestCase
setup do
Flipper.enable(:wakatime_imports_mirrors)
end
teardown do
Flipper.disable(:wakatime_imports_mirrors)
end
def create_source(user:, **attrs)
user.create_heartbeat_import_source!(
{
provider: :wakatime_compatible,
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "import-key",
sync_enabled: true,
status: :idle
}.merge(attrs)
)
end
def queued_jobs_for(job_class)
GoodJob::Job.where(job_class: job_class)
end
test "full-history default schedules backfill windows and re-enqueues coordinator" do
GoodJob::Job.delete_all
user = User.create!(timezone: "UTC")
source = create_source(user: user)
stub_request(:get, "https://wakatime.com/api/v1/users/current/all_time_since_today")
.to_return(
status: 200,
body: {
data: {
range: {
start_date: (Date.current - 10.days).iso8601
}
}
}.to_json,
headers: { "Content-Type" => "application/json" }
)
HeartbeatImportSourceSyncJob.perform_now(source.id)
day_jobs = queued_jobs_for("HeartbeatImportSourceSyncDayJob")
sync_jobs = queued_jobs_for("HeartbeatImportSourceSyncJob")
assert_equal 5, day_jobs.count
assert_equal 1, sync_jobs.count
source.reload
assert source.backfilling?
assert_equal(Date.current - 5.days, source.backfill_cursor_date)
end
test "range override limits scheduled days" do
GoodJob::Job.delete_all
user = User.create!(timezone: "UTC")
start_date = Date.current - 2.days
end_date = Date.current - 1.day
source = create_source(
user: user,
initial_backfill_start_date: start_date,
initial_backfill_end_date: end_date
)
HeartbeatImportSourceSyncJob.perform_now(source.id)
day_jobs = queued_jobs_for("HeartbeatImportSourceSyncDayJob")
day_args = day_jobs.map { |job| job.serialized_params.fetch("arguments").last }
assert_equal 2, day_jobs.count
assert_includes day_args, start_date.iso8601
assert_includes day_args, end_date.iso8601
end
test "ongoing sync enqueues today and yesterday" do
GoodJob::Job.delete_all
user = User.create!(timezone: "UTC")
source = create_source(
user: user,
status: :syncing,
initial_backfill_start_date: Date.current - 7.days,
initial_backfill_end_date: Date.current,
backfill_cursor_date: nil,
last_synced_at: Time.current
)
HeartbeatImportSourceSyncJob.perform_now(source.id)
day_jobs = queued_jobs_for("HeartbeatImportSourceSyncDayJob")
scheduled_dates = day_jobs.map { |job| Date.iso8601(job.serialized_params.fetch("arguments").last) }
assert_equal 2, day_jobs.count
assert_includes scheduled_dates, Date.current
assert_includes scheduled_dates, Date.yesterday
end
test "day job imports and dedupes by fields_hash" do
user = User.create!(timezone: "UTC")
source = create_source(user: user, status: :syncing)
timestamp = Time.current.to_f
payload = [
{
entity: "src/a.rb",
type: "file",
category: "coding",
project: "alpha",
language: "Ruby",
editor: "VS Code",
time: timestamp
},
{
entity: "src/a.rb",
type: "file",
category: "coding",
project: "alpha",
language: "Ruby",
editor: "VS Code",
time: timestamp
}
]
stub_request(:get, "https://wakatime.com/api/v1/users/current/heartbeats")
.with(query: { "date" => Date.current.iso8601 })
.to_return(
status: 200,
body: { data: payload }.to_json,
headers: { "Content-Type" => "application/json" }
)
HeartbeatImportSourceSyncDayJob.perform_now(source.id, Date.current.iso8601)
assert_equal 1, user.heartbeats.where(source_type: :wakapi_import).count
assert source.reload.last_synced_at.present?
end
test "day job pauses source on auth errors" do
user = User.create!(timezone: "UTC")
source = create_source(user: user, status: :syncing)
stub_request(:get, "https://wakatime.com/api/v1/users/current/heartbeats")
.with(query: { "date" => Date.current.iso8601 })
.to_return(status: 401, body: "{}")
HeartbeatImportSourceSyncDayJob.perform_now(source.id, Date.current.iso8601)
source.reload
assert source.paused?
assert_not source.sync_enabled
assert_includes source.last_error_message, "Authentication failed"
end
test "day job marks transient errors for retry" do
user = User.create!(timezone: "UTC")
source = create_source(user: user, status: :syncing)
stub_request(:get, "https://wakatime.com/api/v1/users/current/heartbeats")
.with(query: { "date" => Date.current.iso8601 })
.to_return(status: 500, body: "{}")
assert_raises(WakatimeCompatibleClient::TransientError) do
HeartbeatImportSourceSyncDayJob.new.perform(source.id, Date.current.iso8601)
end
source.reload
assert source.failed?
assert_equal 1, source.consecutive_failures
end
test "coordinator does nothing when imports and mirrors are disabled" do
GoodJob::Job.delete_all
user = User.create!(timezone: "UTC")
source = create_source(user: user)
Flipper.disable(:wakatime_imports_mirrors)
HeartbeatImportSourceSyncJob.perform_now(source.id)
assert_equal 0, queued_jobs_for("HeartbeatImportSourceSyncDayJob").count
assert_equal "idle", source.reload.status
end
end

View file

@ -1,48 +0,0 @@
require "test_helper"
class MirrorFanoutEnqueueJobTest < ActiveJob::TestCase
setup do
Flipper.enable(:wakatime_imports_mirrors)
end
teardown do
Flipper.disable(:wakatime_imports_mirrors)
end
test "debounce prevents enqueue storms per user" do
original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new
GoodJob::Job.delete_all
user = User.create!(timezone: "UTC")
user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key-1"
)
user.wakatime_mirrors.create!(
endpoint_url: "https://wakapi.dev/api/compat/wakatime/v1",
encrypted_api_key: "mirror-key-2"
)
assert_difference -> { GoodJob::Job.where(job_class: "WakatimeMirrorSyncJob").count }, 2 do
MirrorFanoutEnqueueJob.perform_now(user.id)
MirrorFanoutEnqueueJob.perform_now(user.id)
end
ensure
Rails.cache = original_cache
end
test "does not enqueue mirror sync when imports and mirrors are disabled" do
GoodJob::Job.delete_all
user = User.create!(timezone: "UTC")
user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
Flipper.disable(:wakatime_imports_mirrors)
assert_no_difference -> { GoodJob::Job.where(job_class: "WakatimeMirrorSyncJob").count } do
MirrorFanoutEnqueueJob.perform_now(user.id)
end
end
end

View file

@ -1,344 +0,0 @@
require "test_helper"
require "webmock/minitest"
require "socket"
require "net/http"
require "timeout"
class WakatimeMirrorSyncJobTest < ActiveJob::TestCase
setup do
Flipper.enable(:wakatime_imports_mirrors)
end
teardown do
Flipper.disable(:wakatime_imports_mirrors)
end
class MockWakatimeServer
attr_reader :base_url, :port
def initialize
@requests = Queue.new
@server = TCPServer.new("0.0.0.0", 0)
@stopped = false
@clients = []
@mutex = Mutex.new
@port = @server.addr[1]
@base_url = "http://127.0.0.2:#{@port}/api/v1"
end
def start
@thread = Thread.new do
loop do
break if @stopped
socket = @server.accept
@mutex.synchronize { @clients << socket }
handle_client(socket)
rescue IOError, Errno::EBADF
break
end
end
wait_until_ready!
end
def stop
@stopped = true
@server.close unless @server.closed?
@mutex.synchronize do
@clients.each { |client| client.close unless client.closed? }
@clients.clear
end
@thread&.join(2)
end
def pop_requests
requests = []
loop do
requests << @requests.pop(true)
end
rescue ThreadError
requests
end
private
def handle_client(socket)
request_line = socket.gets
return if request_line.nil?
_method, path, = request_line.split(" ")
headers = {}
while (line = socket.gets)
break if line == "\r\n"
key, value = line.split(":", 2)
headers[key.to_s.strip.downcase] = value.to_s.strip
end
content_length = headers.fetch("content-length", "0").to_i
body = content_length.positive? ? socket.read(content_length).to_s : ""
if path == "/api/v1/users/current/heartbeats.bulk"
@requests << {
path: path,
body: body,
authorization: headers["authorization"]
}
respond(socket, 201, "{}")
elsif path == "/__health"
respond(socket, 200, "{}")
else
respond(socket, 404, "{}")
end
ensure
@mutex.synchronize { @clients.delete(socket) }
socket.close unless socket.closed?
end
def respond(socket, status, body)
phrase = status == 200 ? "OK" : status == 201 ? "Created" : "Not Found"
socket.write("HTTP/1.1 #{status} #{phrase}\r\n")
socket.write("Content-Type: application/json\r\n")
socket.write("Content-Length: #{body.bytesize}\r\n")
socket.write("Connection: close\r\n")
socket.write("\r\n")
socket.write(body)
end
def wait_until_ready!
Timeout.timeout(5) do
loop do
begin
response = Net::HTTP.get_response(URI("http://127.0.0.2:#{@port}/__health"))
return if response.is_a?(Net::HTTPSuccess)
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
end
sleep 0.05
end
end
end
end
def create_heartbeat(user:, source_type:, entity:, project: "mirror-project", at_time: Time.current)
user.heartbeats.create!(
entity: entity,
type: "file",
category: "coding",
time: at_time.to_f,
project: project,
source_type: source_type
)
end
test "sync sends only direct heartbeats in chunks of 25 and advances cursor" do
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
direct_heartbeats = 30.times.map do |index|
create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/direct_#{index}.rb",
project: "direct-project",
at_time: Time.current + index.seconds
)
end
5.times do |index|
create_heartbeat(
user: user,
source_type: :wakapi_import,
entity: "src/imported_#{index}.rb",
project: "import-project",
at_time: Time.current + (100 + index).seconds
)
end
payload_batches = []
stub_request(:post, "https://wakatime.com/api/v1/users/current/heartbeats.bulk")
.to_return do |request|
payload_batches << JSON.parse(request.body)
{ status: 201, body: "{}", headers: { "Content-Type" => "application/json" } }
end
with_development_env do
WakatimeMirrorSyncJob.perform_now(mirror.id)
end
assert_equal [ 25, 5 ], payload_batches.map(&:size)
assert_equal 30, payload_batches.flatten.size
assert payload_batches.flatten.all? { |row| row["project"] == "direct-project" }
assert_equal direct_heartbeats.last.id, mirror.reload.last_synced_heartbeat_id
end
test "sync respects last_synced_heartbeat_id cursor" do
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
first = create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/old.rb",
at_time: Time.current - 1.minute
)
create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/new.rb",
at_time: Time.current
)
mirror.update!(last_synced_heartbeat_id: first.id)
payload_batches = []
stub_request(:post, "https://wakatime.com/api/v1/users/current/heartbeats.bulk")
.to_return do |request|
payload_batches << JSON.parse(request.body)
{ status: 201, body: "{}", headers: { "Content-Type" => "application/json" } }
end
with_development_env do
WakatimeMirrorSyncJob.perform_now(mirror.id)
end
assert_equal 1, payload_batches.flatten.size
assert_equal "src/new.rb", payload_batches.flatten.first["entity"]
end
test "auth failures disable mirror and stop syncing" do
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/direct.rb"
)
stub_request(:post, "https://wakatime.com/api/v1/users/current/heartbeats.bulk")
.to_return(status: 401, body: "{}")
with_development_env do
WakatimeMirrorSyncJob.perform_now(mirror.id)
end
mirror.reload
assert_not mirror.enabled
assert_includes mirror.last_error_message, "Authentication failed"
assert mirror.last_error_at.present?
assert_equal 1, mirror.consecutive_failures
end
test "transient failures keep mirror enabled and raise for retry" do
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/direct.rb"
)
stub_request(:post, "https://wakatime.com/api/v1/users/current/heartbeats.bulk")
.to_return(status: 500, body: "{}")
assert_raises(WakatimeMirrorSyncJob::MirrorTransientError) do
WakatimeMirrorSyncJob.new.perform(mirror.id)
end
mirror.reload
assert mirror.enabled
assert_equal 1, mirror.consecutive_failures
end
test "sync posts to a real wakatime-compatible mock server on a random port" do
WebMock.allow_net_connect!
server = MockWakatimeServer.new
server.start
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
mirror.update_column(:endpoint_url, server.base_url)
create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/direct_1.rb",
project: "direct-project"
)
create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/direct_2.rb",
project: "direct-project"
)
create_heartbeat(
user: user,
source_type: :wakapi_import,
entity: "src/imported.rb",
project: "import-project"
)
with_development_env do
WakatimeMirrorSyncJob.perform_now(mirror.id)
end
requests = server.pop_requests
assert_equal 1, requests.length
assert_operator server.port, :>, 0
payload = JSON.parse(requests.first.fetch(:body))
assert_equal 2, payload.length
assert_equal [ "src/direct_1.rb", "src/direct_2.rb" ], payload.map { |row| row["entity"] }
assert_match(/\ABasic /, requests.first.fetch(:authorization).to_s)
ensure
server&.stop
WebMock.disable_net_connect!(allow_localhost: false)
end
test "does nothing when imports and mirrors are disabled" do
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
create_heartbeat(
user: user,
source_type: :direct_entry,
entity: "src/direct.rb"
)
Flipper.disable(:wakatime_imports_mirrors)
stub_request(:post, "https://wakatime.com/api/v1/users/current/heartbeats.bulk")
.to_return(status: 201, body: "{}")
WakatimeMirrorSyncJob.perform_now(mirror.id)
assert_not_requested :post, "https://wakatime.com/api/v1/users/current/heartbeats.bulk"
end
private
def with_development_env
rails_singleton = class << Rails; self; end
rails_singleton.alias_method :__original_env_for_test, :env
rails_singleton.define_method(:env) { ActiveSupport::StringInquirer.new("development") }
yield
ensure
rails_singleton.remove_method :env
rails_singleton.alias_method :env, :__original_env_for_test
rails_singleton.remove_method :__original_env_for_test
end
end

View file

@ -0,0 +1,44 @@
require "test_helper"
class HeartbeatImportRunTest < ActiveSupport::TestCase
test "requires api key for remote imports on create" do
run = HeartbeatImportRun.new(
user: User.create!(timezone: "UTC"),
source_kind: :wakatime_dump,
state: :queued
)
assert_not run.valid?
assert_includes run.errors[:encrypted_api_key], "can't be blank"
end
test "remote cooldown helper returns future timestamp for recent remote import" do
user = User.create!(timezone: "UTC")
run = user.heartbeat_import_runs.create!(
source_kind: :wakatime_dump,
state: :completed,
encrypted_api_key: "secret",
remote_requested_at: 2.hours.ago
)
cooldown_until = HeartbeatImportRun.remote_cooldown_until_for(user)
assert_in_delta run.remote_requested_at + 4.hours, cooldown_until, 1.second
end
test "active_for returns the latest active import" do
user = User.create!(timezone: "UTC")
user.heartbeat_import_runs.create!(
source_kind: :dev_upload,
state: :completed,
source_filename: "old.json"
)
latest = user.heartbeat_import_runs.create!(
source_kind: :dev_upload,
state: :queued,
source_filename: "new.json"
)
assert_equal latest, HeartbeatImportRun.active_for(user)
end
end

View file

@ -1,50 +0,0 @@
require "test_helper"
class HeartbeatImportSourceTest < ActiveSupport::TestCase
test "validates one source per user" do
user = User.create!(timezone: "UTC")
HeartbeatImportSource.create!(
user: user,
provider: :wakatime_compatible,
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "abc123"
)
duplicate = HeartbeatImportSource.new(
user: user,
provider: :wakatime_compatible,
endpoint_url: "https://wakapi.dev/api/compat/wakatime/v1",
encrypted_api_key: "xyz789"
)
assert_not duplicate.valid?
assert_includes duplicate.errors[:user_id], "has already been taken"
end
test "requires https endpoint outside development" do
source = HeartbeatImportSource.new(
user: User.create!(timezone: "UTC"),
provider: :wakatime_compatible,
endpoint_url: "http://example.com/api/v1",
encrypted_api_key: "abc123"
)
assert_not source.valid?
assert_includes source.errors[:endpoint_url], "must use https"
end
test "validates backfill date range order" do
source = HeartbeatImportSource.new(
user: User.create!(timezone: "UTC"),
provider: :wakatime_compatible,
endpoint_url: "https://example.com/api/v1",
encrypted_api_key: "abc123",
initial_backfill_start_date: Date.new(2026, 2, 10),
initial_backfill_end_date: Date.new(2026, 2, 1)
)
assert_not source.valid?
assert_includes source.errors[:initial_backfill_end_date], "must be on or after the start date"
end
end

View file

@ -29,4 +29,32 @@ class UserTest < ActiveSupport::TestCase
assert_equal "gruvbox_dark", metadata[:value] assert_equal "gruvbox_dark", metadata[:value]
end end
test "flipper id uses the user id" do
user = User.create!(timezone: "UTC")
assert_equal "User;#{user.id}", user.flipper_id
end
test "active remote heartbeat import run only counts remote imports" do
user = User.create!(timezone: "UTC")
assert_not user.active_remote_heartbeat_import_run?
user.heartbeat_import_runs.create!(
source_kind: :dev_upload,
state: :queued,
source_filename: "dev.json"
)
assert_not user.active_remote_heartbeat_import_run?
user.heartbeat_import_runs.create!(
source_kind: :wakatime_dump,
state: :waiting_for_dump,
encrypted_api_key: "secret"
)
assert user.active_remote_heartbeat_import_run?
end
end end

View file

@ -1,52 +0,0 @@
require "test_helper"
class WakatimeMirrorTest < ActiveSupport::TestCase
def create_direct_heartbeat(user, at_time)
user.heartbeats.create!(
entity: "src/file.rb",
type: "file",
category: "coding",
time: at_time.to_f,
project: "mirror-test",
source_type: :direct_entry
)
end
test "initializes cursor at current heartbeat tip on create" do
user = User.create!(timezone: "UTC")
first = create_direct_heartbeat(user, Time.current - 5.minutes)
second = create_direct_heartbeat(user, Time.current - 1.minute)
mirror = user.wakatime_mirrors.create!(
endpoint_url: "https://wakatime.com/api/v1",
encrypted_api_key: "mirror-key"
)
assert_equal second.id, mirror.last_synced_heartbeat_id
assert_operator second.id, :>, first.id
end
test "rejects endpoints that point to hackatime host" do
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.build(
endpoint_url: "https://hackatime.hackclub.com/api/v1",
encrypted_api_key: "mirror-key"
)
assert_not mirror.valid?
assert_includes mirror.errors[:endpoint_url], "cannot target this Hackatime host"
end
test "rejects app host equivalent endpoints" do
user = User.create!(timezone: "UTC")
mirror = user.wakatime_mirrors.build(
endpoint_url: "https://example.com/api/v1",
encrypted_api_key: "mirror-key"
)
mirror.request_host = "example.com"
assert_not mirror.valid?
assert_includes mirror.errors[:endpoint_url], "cannot target this Hackatime host"
end
end

View file

@ -0,0 +1,56 @@
require "test_helper"
require "webmock/minitest"
class HeartbeatImportDumpClientTest < ActiveSupport::TestCase
test "request_dump sends basic auth with the raw api key" do
client = HeartbeatImportDumpClient.new(source_kind: :hackatime_v1_dump, api_key: "secret-key")
stub = stub_request(:post, "https://waka.hackclub.com/api/v1/users/current/data_dumps")
.with(
headers: {
"Authorization" => "Basic #{Base64.strict_encode64("secret-key")}",
"Accept" => "application/json",
"Content-Type" => "application/json"
},
body: { type: "heartbeats", email_when_finished: false }.to_json
)
.to_return(
status: 201,
body: {
data: {
id: "dump-123",
status: "Processing",
percent_complete: 0,
type: "heartbeats",
is_processing: true,
is_stuck: false,
has_failed: false
}
}.to_json,
headers: { "Content-Type" => "application/json" }
)
response = client.request_dump
assert_requested stub
assert_equal "dump-123", response[:id]
end
test "download_dump reuses auth header for same-host downloads" do
client = HeartbeatImportDumpClient.new(source_kind: :hackatime_v1_dump, api_key: "secret-key")
stub = stub_request(:get, "https://waka.hackclub.com/downloads/dump-123.json")
.with(
headers: {
"Authorization" => "Basic #{Base64.strict_encode64("secret-key")}",
"Accept" => "application/json,application/octet-stream,*/*"
}
)
.to_return(status: 200, body: '{"heartbeats":[]}')
body = client.download_dump("https://waka.hackclub.com/downloads/dump-123.json")
assert_requested stub
assert_equal '{"heartbeats":[]}', body
end
end

View file

@ -0,0 +1,72 @@
require "test_helper"
class HeartbeatImportServiceTest < ActiveSupport::TestCase
test "deduplicates imported heartbeats by fields hash" do
user = User.create!(timezone: "UTC")
file_content = {
heartbeats: [
{
entity: "/tmp/test.rb",
type: "file",
time: 1_700_000_000.0,
project: "hackatime",
language: "Ruby",
is_write: true
},
{
entity: "/tmp/test.rb",
type: "file",
time: 1_700_000_000.0,
project: "hackatime",
language: "Ruby",
is_write: true
}
]
}.to_json
result = HeartbeatImportService.import_from_file(file_content, user)
assert result[:success]
assert_equal 2, result[:total_count]
assert_equal 1, result[:imported_count]
assert_equal 1, result[:skipped_count]
assert_equal 1, user.heartbeats.count
end
test "imports heartbeats from wakatime data dump day groups" do
user = User.create!(timezone: "UTC")
file_content = {
range: { start: 1_727_905_169, end: 1_727_905_177 },
days: [
{
date: "2024-10-02",
heartbeats: [
{
entity: "/home/skyfall/tavern/manifest.json",
type: "file",
time: 1_727_905_177,
category: "coding",
project: "tavern",
language: "JSON",
editor: "vscode",
operating_system: "Linux",
machine_name_id: "skyfall-pc",
user_agent_id: "wakatime/v1.102.1",
is_write: true
}
]
}
]
}.to_json
result = HeartbeatImportService.import_from_file(file_content, user)
assert result[:success]
assert_equal 1, result[:total_count]
assert_equal 1, result[:imported_count]
heartbeat = user.heartbeats.order(:created_at).last
assert_equal "skyfall-pc", heartbeat.machine
assert_equal "wakatime/v1.102.1", heartbeat.user_agent
end
end

View file

@ -13,7 +13,8 @@ class AccessSettingsTest < ApplicationSystemTestCase
test "access settings page renders key sections" do test "access settings page renders key sections" do
assert_settings_page( assert_settings_page(
path: my_settings_access_path, path: my_settings_access_path,
marker_text: "Time Tracking Setup" marker_text: "Time Tracking Setup",
card_count: 4
) )
assert_text "Extension Display" assert_text "Extension Display"

View file

@ -1,25 +0,0 @@
require "application_system_test_case"
require_relative "test_helpers"
class AdminSettingsTest < ApplicationSystemTestCase
include SettingsSystemTestHelpers
setup do
@user = User.create!(timezone: "UTC")
sign_in_as(@user)
end
test "admin settings redirects non-admin users" do
visit my_settings_admin_path
assert_current_path my_settings_profile_path, ignore_query: true
assert_text "You are not authorized to access this page"
end
test "admin settings page is available to admin users" do
@user.update!(admin_level: :admin)
visit my_settings_admin_path
assert_text "Mirror and import controls are available under Data settings for all users."
end
end

View file

@ -12,7 +12,8 @@ class BadgesSettingsTest < ApplicationSystemTestCase
test "badges settings page renders key sections" do test "badges settings page renders key sections" do
assert_settings_page( assert_settings_page(
path: my_settings_badges_path, path: my_settings_badges_path,
marker_text: "Stats Badges" marker_text: "Stats Badges",
card_count: 3
) )
assert_text "Markscribe Template" assert_text "Markscribe Template"

View file

@ -5,19 +5,21 @@ class DataSettingsTest < ApplicationSystemTestCase
include SettingsSystemTestHelpers include SettingsSystemTestHelpers
setup do setup do
Flipper.enable(:wakatime_imports_mirrors)
@user = User.create!(timezone: "UTC") @user = User.create!(timezone: "UTC")
sign_in_as(@user) sign_in_as(@user)
end end
teardown do teardown do
Flipper.disable(:wakatime_imports_mirrors) Flipper.disable(:imports)
end end
test "data settings page renders key sections" do test "data settings page renders key sections" do
Flipper.enable_actor(:imports, @user)
assert_settings_page( assert_settings_page(
path: my_settings_data_path, path: my_settings_data_path,
marker_text: "Migration Assistant" marker_text: "Imports",
card_count: 3
) )
assert_text "Download Data" assert_text "Download Data"
@ -47,62 +49,39 @@ class DataSettingsTest < ApplicationSystemTestCase
assert_text "I changed my mind" assert_text "I changed my mind"
end end
test "regular user can add and delete mirror endpoint from data settings" do test "imports card is visible when feature is enabled for the user" do
Flipper.enable_actor(:imports, @user)
visit my_settings_data_path visit my_settings_data_path
endpoint_url = "https://example-wakatime.invalid/api/v1" assert_text "Imports"
fill_in "mirror_endpoint_url", with: endpoint_url assert_text "WakaTime"
fill_in "mirror_key", with: "mirror-key-#{SecureRandom.hex(8)}" assert_text "Hackatime v1"
assert_field "remote_import_api_key"
assert_difference -> { @user.reload.wakatime_mirrors.count }, +1 do assert_text "Start remote import"
click_on "Add mirror"
assert_text "WakaTime mirror added successfully"
end
assert_text endpoint_url
assert_difference -> { @user.reload.wakatime_mirrors.count }, -1 do
accept_confirm do
click_on "Delete mirror"
end
assert_text "WakaTime mirror removed successfully"
end
end end
test "data settings rejects hackatime mirror endpoint" do test "imports card is hidden when feature is disabled" do
visit my_settings_data_path visit my_settings_data_path
fill_in "mirror_endpoint_url", with: "https://hackatime.hackclub.com/api/v1" assert_no_text "Request a one-time heartbeat dump from WakaTime or legacy Hackatime."
fill_in "mirror_key", with: "mirror-key-#{SecureRandom.hex(8)}" assert_no_field "remote_import_api_key"
click_on "Add mirror" assert_no_button "Start remote import"
assert_text "cannot target this Hackatime host"
assert_equal 0, @user.reload.wakatime_mirrors.count
end end
test "data settings can configure import source and show status panel" do test "data settings shows remote import cooldown notice" do
visit my_settings_data_path Flipper.enable_actor(:imports, @user)
fill_in "import_endpoint_url", with: "https://wakatime.com/api/v1" @user.heartbeat_import_runs.create!(
fill_in "import_api_key", with: "import-key-#{SecureRandom.hex(8)}" source_kind: :wakatime_dump,
state: :waiting_for_dump,
assert_difference -> { HeartbeatImportSource.count }, +1 do encrypted_api_key: "secret",
click_on "Create source" remote_requested_at: 1.hour.ago,
assert_text "Import source configured successfully." message: "Waiting..."
end )
assert_text "Status:"
assert_text "Imported:"
assert_button "Sync now"
end
test "imports and mirrors section is hidden when feature is disabled" do
Flipper.disable(:wakatime_imports_mirrors)
visit my_settings_data_path visit my_settings_data_path
assert_no_text "Imports & Mirrors" assert_text "Remote imports can be started again after"
assert_no_field "mirror_endpoint_url"
assert_no_field "import_endpoint_url"
end end
end end

View file

@ -12,7 +12,8 @@ class IntegrationsSettingsTest < ApplicationSystemTestCase
test "integrations settings page renders key sections" do test "integrations settings page renders key sections" do
assert_settings_page( assert_settings_page(
path: my_settings_integrations_path, path: my_settings_integrations_path,
marker_text: "Slack Status Sync" marker_text: "Slack Status Sync",
card_count: 4
) )
assert_text "Slack Channel Notifications" assert_text "Slack Channel Notifications"

View file

@ -15,6 +15,15 @@ class ProfileSettingsTest < ApplicationSystemTestCase
assert_current_path my_settings_path, ignore_query: true assert_current_path my_settings_path, ignore_query: true
assert_text "Settings" assert_text "Settings"
assert_text "Region and Timezone" assert_text "Region and Timezone"
assert_selector "[data-settings-card]", minimum: 4
end
test "settings hash redirects to the matching settings page and subsection" do
visit "#{my_settings_profile_path}#user_api_key"
assert_current_path my_settings_access_path, ignore_query: true
assert_text "API Key"
assert_selector "[data-settings-subnav-item][data-active='true']", text: "API key"
end end
test "profile settings updates country and username" do test "profile settings updates country and username" do

View file

@ -1,12 +1,15 @@
module SettingsSystemTestHelpers module SettingsSystemTestHelpers
private private
def assert_settings_page(path:, marker_text:) def assert_settings_page(path:, marker_text:, card_count: 1)
visit path visit path
assert_current_path path, ignore_query: true assert_current_path path, ignore_query: true
assert_text "Settings" assert_text "Settings"
assert_text marker_text assert_text marker_text
assert_selector "[data-settings-shell]"
assert_selector "[data-settings-content]"
assert_selector "[data-settings-card]", minimum: card_count
end end
def choose_select_option(select_id, option_text) def choose_select_option(select_id, option_text)