mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
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:
parent
ea9596cb7d
commit
667d3a7c93
78 changed files with 2748 additions and 3996 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
58
Gemfile.lock
58
Gemfile.lock
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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" })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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] }
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
122
app/jobs/heartbeat_import_dump_job.rb
Normal file
122
app/jobs/heartbeat_import_dump_job.rb
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
class Hackatime::User < HackatimeRecord
|
|
||||||
self.table_name = "users"
|
|
||||||
end
|
|
||||||
|
|
@ -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
|
|
||||||
88
app/models/heartbeat_import_run.rb
Normal file
88
app/models/heartbeat_import_run.rb
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
116
app/services/heartbeat_import_dump_client.rb
Normal file
116
app/services/heartbeat_import_dump_client.rb
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
76
bun.lock
76
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
29
db/migrate/20260312134424_create_heartbeat_import_runs.rb
Normal file
29
db/migrate/20260312134424_create_heartbeat_import_runs.rb
Normal 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
29
db/schema.rb
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
130
test/jobs/heartbeat_import_dump_job_test.rb
Normal file
130
test/jobs/heartbeat_import_dump_job_test.rb
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
44
test/models/heartbeat_import_run_test.rb
Normal file
44
test/models/heartbeat_import_run_test.rb
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
56
test/services/heartbeat_import_dump_client_test.rb
Normal file
56
test/services/heartbeat_import_dump_client_test.rb
Normal 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
|
||||||
72
test/services/heartbeat_import_service_test.rb
Normal file
72
test/services/heartbeat_import_service_test.rb
Normal 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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue