mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 22:15:14 +00:00
Merge branch 'main' into oauth-creds-rotate
This commit is contained in:
commit
32a10f34fe
47 changed files with 836 additions and 441 deletions
|
|
@ -58,4 +58,8 @@ MAIL_HACKCLUB_TOKEN=replace_me
|
|||
|
||||
# Hack Club Account
|
||||
HCA_CLIENT_ID=your_hackclub_account_client_id_here
|
||||
HCA_CLIENT_SECRET=your_hackclub_account_secret_id_here
|
||||
HCA_CLIENT_SECRET=your_hackclub_account_secret_id_here
|
||||
|
||||
# PostHog Analytics
|
||||
POSTHOG_API_KEY=your_posthog_api_key_here
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -116,10 +116,14 @@ jobs:
|
|||
run: |
|
||||
bin/rails db:create RAILS_ENV=test
|
||||
bin/rails db:schema:load RAILS_ENV=test
|
||||
# Create additional test databases
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime;"
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log;"
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;"
|
||||
# Create additional test databases used by multi-db models
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_wakatime;" || true
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_sailors_log;" || true
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;" || true
|
||||
# Mirror schema from primary test DB so cross-db models can query safely in tests
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_wakatime
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_sailors_log
|
||||
pg_dump -h localhost -U postgres --schema-only --no-owner --no-privileges test_db | psql -h localhost -U postgres test_warehouse
|
||||
bin/rails test
|
||||
|
||||
- name: Ensure Swagger docs are up to date
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ RUN git config --system http.timeout 30 && \
|
|||
git config --system http.lowSpeedLimit 1000 && \
|
||||
git config --system http.lowSpeedTime 10
|
||||
EXPOSE 3000
|
||||
EXPOSE 3036
|
||||
|
||||
# Start the main process
|
||||
CMD ["rails", "server", "-b", "0.0.0.0"]
|
||||
|
|
@ -50,9 +50,7 @@ COPY . .
|
|||
# Precompile bootsnap code for faster boot times
|
||||
RUN bundle exec bootsnap precompile app/ lib/
|
||||
|
||||
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
|
||||
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails tailwindcss:build
|
||||
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||
# Worker doesn't serve assets, skip asset precompilation
|
||||
|
||||
# Final stage for app image
|
||||
FROM base
|
||||
|
|
|
|||
5
Gemfile
5
Gemfile
|
|
@ -70,8 +70,6 @@ gem "dotenv-rails"
|
|||
# Added from the code block
|
||||
gem "http"
|
||||
|
||||
gem "public_activity"
|
||||
|
||||
# Bulk import
|
||||
gem "activerecord-import"
|
||||
|
||||
|
|
@ -86,6 +84,9 @@ gem "flamegraph"
|
|||
|
||||
gem "skylight"
|
||||
|
||||
# Analytics
|
||||
gem "posthog-ruby"
|
||||
|
||||
gem "geocoder"
|
||||
|
||||
# Airtable syncing
|
||||
|
|
|
|||
36
Gemfile.lock
36
Gemfile.lock
|
|
@ -92,7 +92,7 @@ GEM
|
|||
smart_properties
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.22.0)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (8.0.2)
|
||||
racc
|
||||
|
|
@ -151,7 +151,7 @@ GEM
|
|||
tzinfo
|
||||
faker (3.6.0)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.0)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
|
|
@ -225,14 +225,15 @@ GEM
|
|||
inertia_rails (3.17.0)
|
||||
railties (>= 6)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
irb (1.17.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.18.0)
|
||||
json (2.18.1)
|
||||
json-schema (6.1.0)
|
||||
addressable (~> 2.8)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
|
|
@ -286,7 +287,7 @@ GEM
|
|||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.8)
|
||||
connection_pool (>= 2.2.4, < 4)
|
||||
net-imap (0.6.2)
|
||||
net-imap (0.6.3)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
|
@ -319,8 +320,9 @@ GEM
|
|||
faraday (>= 1.0, < 3.0)
|
||||
faraday-net_http_persistent
|
||||
net-http-persistent
|
||||
oj (3.16.14)
|
||||
oj (3.16.15)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
ostruct (0.6.3)
|
||||
paper_trail (17.0.0)
|
||||
activerecord (>= 7.1)
|
||||
|
|
@ -335,6 +337,8 @@ GEM
|
|||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
posthog-ruby (3.4.0)
|
||||
concurrent-ruby (~> 1)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
|
|
@ -346,11 +350,6 @@ GEM
|
|||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_activity (3.0.2)
|
||||
actionpack (>= 6.1)
|
||||
activerecord (>= 6.1)
|
||||
i18n (>= 0.5.0)
|
||||
railties (>= 6.1)
|
||||
public_suffix (7.0.2)
|
||||
puma (7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
|
|
@ -412,7 +411,7 @@ GEM
|
|||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (7.1.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
|
|
@ -451,7 +450,7 @@ GEM
|
|||
rswag-ui (2.17.0)
|
||||
actionpack (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rubocop (1.84.0)
|
||||
rubocop (1.84.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
|
|
@ -493,12 +492,13 @@ GEM
|
|||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (6.3.0)
|
||||
sentry-rails (6.3.1)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 6.3.0)
|
||||
sentry-ruby (6.3.0)
|
||||
sentry-ruby (~> 6.3.1)
|
||||
sentry-ruby (6.3.1)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
logger
|
||||
skylight (7.0.0)
|
||||
activesupport (>= 7.1.0)
|
||||
slack-ruby-client (3.1.0)
|
||||
|
|
@ -532,7 +532,7 @@ GEM
|
|||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
stackprof (0.2.27)
|
||||
stackprof (0.2.28)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
|
|
@ -633,8 +633,8 @@ DEPENDENCIES
|
|||
oj
|
||||
paper_trail
|
||||
pg
|
||||
posthog-ruby
|
||||
propshaft
|
||||
public_activity
|
||||
puma (>= 5.0)
|
||||
query_count
|
||||
rack-attack
|
||||
|
|
|
|||
|
|
@ -250,12 +250,13 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
|||
new_heartbeat.save! if new_heartbeat.changed?
|
||||
end
|
||||
queue_project_mapping(heartbeat[:project])
|
||||
queue_heartbeat_public_activity(@user.id, heartbeat[:project]) if new_heartbeat.persisted?
|
||||
results << [ new_heartbeat.attributes, 201 ]
|
||||
rescue => e
|
||||
Rails.logger.error("Error creating heartbeat: #{e.class.name} #{e.message}")
|
||||
results << [ { error: e.message, type: e.class.name }, 422 ]
|
||||
end
|
||||
|
||||
PosthogService.capture_once_per_day(@user, "heartbeat_sent", { heartbeat_count: heartbeat_array.size })
|
||||
results
|
||||
end
|
||||
|
||||
|
|
@ -269,17 +270,6 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
|
|||
Rails.logger.error("Error queuing project mapping: #{e.class.name} #{e.message}")
|
||||
end
|
||||
|
||||
def queue_heartbeat_public_activity(user_id, project_name)
|
||||
# only queue the job once per minute
|
||||
Rails.cache.fetch("heartbeat_public_activity_#{user_id}_#{project_name}", expires_in: 30.seconds) do
|
||||
# temporarily disable
|
||||
# CreateHeartbeatActivityJob.perform_later(user_id, project_name)
|
||||
end
|
||||
rescue => e
|
||||
# never raise an error here because it will break the heartbeat flow
|
||||
Rails.logger.error("Error queuing heartbeat public activity: #{e.class.name} #{e.message}")
|
||||
end
|
||||
|
||||
def check_lockout
|
||||
return unless @user&.pending_deletion?
|
||||
render json: { error: "Account pending deletion" }, status: :forbidden
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ class ApplicationController < ActionController::Base
|
|||
before_action :initialize_cache_counters
|
||||
before_action :try_rack_mini_profiler_enable
|
||||
before_action :track_request
|
||||
before_action :set_public_activity
|
||||
before_action :enforce_lockout
|
||||
before_action :set_cache_headers
|
||||
|
||||
around_action :switch_time_zone, if: :current_user
|
||||
|
||||
|
|
@ -20,11 +20,6 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
private
|
||||
|
||||
def set_public_activity
|
||||
return unless Flipper.enabled?(:public_activity_log, current_user)
|
||||
@activities = PublicActivity::Activity.limit(25).order(created_at: :desc).includes(:owner, :trackable)
|
||||
end
|
||||
|
||||
def sentry_context
|
||||
Sentry.set_user(
|
||||
id: current_user.id,
|
||||
|
|
@ -72,6 +67,10 @@ class ApplicationController < ActionController::Base
|
|||
redirect_to deletion_path
|
||||
end
|
||||
|
||||
def set_cache_headers
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
end
|
||||
|
||||
def initialize_cache_counters
|
||||
Thread.current[:cache_hits] = 0
|
||||
Thread.current[:cache_misses] = 0
|
||||
|
|
|
|||
|
|
@ -21,16 +21,13 @@ class InertiaController < ApplicationController
|
|||
{
|
||||
flash: inertia_flash_messages,
|
||||
user_present: current_user.present?,
|
||||
user_mention_html: current_user ? render_to_string(partial: "shared/user_mention", locals: { user: current_user }) : nil,
|
||||
streak_html: current_user ? render_to_string(partial: "static_pages/streak", locals: { user: current_user, show_text: true, turbo_frame: false }) : nil,
|
||||
admin_level_html: current_user ? render_to_string(partial: "static_pages/admin_level", locals: { user: current_user }) : nil,
|
||||
current_user: inertia_nav_current_user,
|
||||
login_path: slack_auth_path,
|
||||
links: inertia_primary_links,
|
||||
dev_links: inertia_dev_links,
|
||||
admin_links: inertia_admin_links,
|
||||
viewer_links: inertia_viewer_links,
|
||||
superadmin_links: inertia_superadmin_links,
|
||||
activities_html: inertia_activities_html
|
||||
superadmin_links: inertia_superadmin_links
|
||||
}
|
||||
end
|
||||
|
||||
|
|
@ -119,18 +116,29 @@ class InertiaController < ApplicationController
|
|||
{ label: label, href: href, active: active, badge: badge }
|
||||
end
|
||||
|
||||
def inertia_activities_html
|
||||
return nil unless defined?(@activities) && @activities.present?
|
||||
helpers.render_activities(@activities)
|
||||
def inertia_nav_current_user
|
||||
return nil unless current_user
|
||||
|
||||
country = current_user.country_code.present? ? ISO3166::Country.new(current_user.country_code) : nil
|
||||
|
||||
{
|
||||
display_name: current_user.display_name,
|
||||
avatar_url: current_user.avatar_url,
|
||||
title: FlavorText.same_user.sample,
|
||||
country_code: current_user.country_code,
|
||||
country_name: country&.common_name,
|
||||
streak_days: current_user.streak_days,
|
||||
admin_level: current_user.admin_level
|
||||
}
|
||||
end
|
||||
|
||||
def inertia_footer_props
|
||||
helpers = ApplicationController.helpers
|
||||
cache = helpers.cache_stats
|
||||
hours = active_users_graph_data.map.with_index do |entry, index|
|
||||
hours = active_users_graph_data.map do |entry|
|
||||
{
|
||||
height: entry[:height],
|
||||
title: "#{helpers.pluralize(index + 1, 'hour')} ago, #{helpers.pluralize(entry[:users], 'people')} logged time. '#{FlavorText.latin_phrases.sample}.'"
|
||||
users: entry[:users]
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
class LeaderboardsController < ApplicationController
|
||||
PER_PAGE = 100
|
||||
LEADERBOARD_SCOPES = %w[global country].freeze
|
||||
|
||||
def index
|
||||
@period_type = validated_period_type
|
||||
load_country_context
|
||||
@leaderboard_scope = validated_leaderboard_scope
|
||||
@leaderboard = LeaderboardService.get(period: @period_type, date: start_date)
|
||||
@leaderboard.nil? ? flash.now[:notice] = "Leaderboard is being updated..." : load_metadata
|
||||
end
|
||||
|
||||
def entries
|
||||
@period_type = validated_period_type
|
||||
load_country_context
|
||||
@leaderboard_scope = validated_leaderboard_scope
|
||||
@leaderboard = LeaderboardService.get(period: @period_type, date: start_date)
|
||||
return head :no_content unless @leaderboard&.persisted?
|
||||
|
||||
page = (params[:page] || 1).to_i
|
||||
@entries = @leaderboard.entries.includes(:user).order(total_seconds: :desc)
|
||||
.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
|
||||
page = [ (params[:page] || 1).to_i, 1 ].max
|
||||
@entries = leaderboard_entries_scope.includes(:user).order(total_seconds: :desc)
|
||||
.offset((page - 1) * PER_PAGE).limit(PER_PAGE)
|
||||
@active_projects = Cache::ActiveProjectsJob.perform_now
|
||||
@offset = (page - 1) * PER_PAGE
|
||||
|
||||
|
|
@ -24,8 +29,33 @@ class LeaderboardsController < ApplicationController
|
|||
private
|
||||
|
||||
def validated_period_type
|
||||
p = (params[:period_type] || "daily").to_sym
|
||||
%i[daily last_7_days].include?(p) ? p : :daily
|
||||
p = (params[:period_type] || "daily").to_s
|
||||
%w[daily last_7_days].include?(p) ? p.to_sym : :daily
|
||||
end
|
||||
|
||||
def validated_leaderboard_scope
|
||||
requested_scope = params[:scope].to_s
|
||||
requested_scope = "global" unless LEADERBOARD_SCOPES.include?(requested_scope)
|
||||
requested_scope = "global" if requested_scope == "country" && !@country_scope_available
|
||||
requested_scope.to_sym
|
||||
end
|
||||
|
||||
def load_country_context
|
||||
country = ISO3166::Country.new(current_user&.country_code)
|
||||
@country_code = country&.alpha2
|
||||
@country_name = country&.common_name
|
||||
@country_scope_available = @country_code.present? && @country_name.present?
|
||||
end
|
||||
|
||||
def country_scope?
|
||||
@leaderboard_scope == :country && @country_scope_available
|
||||
end
|
||||
|
||||
def leaderboard_entries_scope
|
||||
entries_scope = @leaderboard.entries
|
||||
return entries_scope unless country_scope?
|
||||
|
||||
entries_scope.joins(:user).where(users: { country_code: @country_code })
|
||||
end
|
||||
|
||||
def start_date
|
||||
|
|
@ -35,14 +65,17 @@ class LeaderboardsController < ApplicationController
|
|||
def load_metadata
|
||||
return unless @leaderboard.persisted?
|
||||
|
||||
ids = @leaderboard.entries.distinct.pluck(:user_id)
|
||||
entries_scope = leaderboard_entries_scope
|
||||
ids = entries_scope.distinct.pluck(:user_id)
|
||||
@user_on_leaderboard = current_user && ids.include?(current_user.id)
|
||||
@untracked_entries = calculate_untracked_entries(ids) unless @user_on_leaderboard
|
||||
@total_entries = @leaderboard.entries.count
|
||||
@untracked_entries = calculate_untracked_entries(ids) unless @user_on_leaderboard || country_scope?
|
||||
@total_entries = entries_scope.count
|
||||
end
|
||||
|
||||
def calculate_untracked_entries(ids)
|
||||
r = @period_type == :last_7_days ? ((Date.current - 6.days).beginning_of_day...Date.current.end_of_day) : Date.current.all_day
|
||||
Hackatime::Heartbeat.where(time: r).distinct.pluck(:user_id).count { |uid| !ids.include?(uid) }
|
||||
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
|
||||
|
|
|
|||
|
|
@ -33,13 +33,11 @@ class SessionsController < ApplicationController
|
|||
MigrateUserFromHackatimeJob.perform_later(@user.id)
|
||||
end
|
||||
|
||||
if !@user.heartbeats.exists?
|
||||
# User hasn't set up editor yet; preserve return_data already set by hca_new,
|
||||
# only override if a new, safely sanitized continue URL is available.
|
||||
if params[:continue].present?
|
||||
sanitized_continue_url = safe_return_url(params[:continue].presence)
|
||||
session[:return_data] = { "url" => sanitized_continue_url } if sanitized_continue_url.present?
|
||||
end
|
||||
PosthogService.identify(@user)
|
||||
PosthogService.capture(@user, "user_signed_in", { method: "hca" })
|
||||
|
||||
if @user.created_at > 5.seconds.ago
|
||||
session[:return_data] = { "url" => safe_return_url(params[:continue].presence) }
|
||||
Rails.logger.info("Sessions return data: #{session[:return_data]}")
|
||||
redirect_to my_wakatime_setup_path, notice: "Successfully signed in with Hack Club Auth! Welcome!"
|
||||
elsif session[:return_data]&.dig("url").present?
|
||||
|
|
@ -86,14 +84,17 @@ class SessionsController < ApplicationController
|
|||
MigrateUserFromHackatimeJob.perform_later(@user.id)
|
||||
end
|
||||
|
||||
PosthogService.identify(@user)
|
||||
PosthogService.capture(@user, "user_signed_in", { method: "slack" })
|
||||
|
||||
state = JSON.parse(params[:state]) rescue {}
|
||||
if state["close_window"]
|
||||
redirect_to close_window_path
|
||||
elsif !@user.heartbeats.exists?
|
||||
elsif @user.created_at > 5.seconds.ago
|
||||
session[:return_data] = { "url" => safe_return_url(state["continue"].presence) }
|
||||
redirect_to my_wakatime_setup_path, notice: "Successfully signed in with Slack! Welcome!"
|
||||
elsif (continue_url = safe_return_url(state["continue"].presence))
|
||||
redirect_to continue_url, notice: "Successfully signed in with Slack! Welcome!"
|
||||
elsif state["continue"].present? && safe_return_url(state["continue"]).present?
|
||||
redirect_to safe_return_url(state["continue"]), notice: "Successfully signed in with Slack! Welcome!"
|
||||
else
|
||||
redirect_to root_path, notice: "Successfully signed in with Slack! Welcome!"
|
||||
end
|
||||
|
|
@ -137,6 +138,7 @@ class SessionsController < ApplicationController
|
|||
@user = User.from_github_token(params[:code], redirect_uri, current_user)
|
||||
|
||||
if @user&.persisted?
|
||||
PosthogService.capture(@user, "github_linked")
|
||||
redirect_to my_settings_path, notice: "Successfully linked GitHub account!"
|
||||
else
|
||||
Rails.logger.error "Failed to link GitHub account"
|
||||
|
|
@ -254,15 +256,13 @@ class SessionsController < ApplicationController
|
|||
valid_token.mark_used!
|
||||
session[:user_id] = valid_token.user_id
|
||||
session[:return_data] = valid_token.return_data || {}
|
||||
user = User.find(valid_token.user_id)
|
||||
continue_url = safe_return_url(valid_token.continue_param)
|
||||
|
||||
if !user.heartbeats.exists?
|
||||
# User hasn't set up editor yet; send through wakatime setup first
|
||||
session[:return_data]["url"] = continue_url if continue_url.present?
|
||||
redirect_to my_wakatime_setup_path, notice: "Successfully signed in!"
|
||||
elsif continue_url.present?
|
||||
redirect_to continue_url, notice: "Successfully signed in!"
|
||||
user = User.find(valid_token.user_id)
|
||||
PosthogService.identify(user)
|
||||
PosthogService.capture(user, "user_signed_in", { method: "email" })
|
||||
|
||||
if valid_token.continue_param.present? && safe_return_url(valid_token.continue_param).present?
|
||||
redirect_to safe_return_url(valid_token.continue_param), notice: "Successfully signed in!"
|
||||
else
|
||||
redirect_to root_path, notice: "Successfully signed in!"
|
||||
end
|
||||
|
|
@ -304,6 +304,7 @@ class SessionsController < ApplicationController
|
|||
end
|
||||
|
||||
def destroy
|
||||
PosthogService.capture(session[:user_id], "user_signed_out") if session[:user_id]
|
||||
session[:user_id] = nil
|
||||
session[:impersonater_user_id] = nil
|
||||
redirect_to root_path, notice: "Signed out!"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class UsersController < InertiaController
|
|||
if @user.uses_slack_status?
|
||||
@user.update_slack_status
|
||||
end
|
||||
PosthogService.capture(@user, "settings_updated", { fields: user_params.keys })
|
||||
redirect_to is_own_settings? ? my_settings_path : settings_user_path(@user),
|
||||
notice: "Settings updated successfully"
|
||||
else
|
||||
|
|
@ -44,6 +45,7 @@ class UsersController < InertiaController
|
|||
|
||||
new_api_key = @user.api_keys.create!(name: "Hackatime key")
|
||||
|
||||
PosthogService.capture(@user, "api_key_rotated")
|
||||
render json: { token: new_api_key.token }, status: :ok
|
||||
end
|
||||
rescue => e
|
||||
|
|
@ -54,33 +56,37 @@ class UsersController < InertiaController
|
|||
def wakatime_setup
|
||||
api_key = current_user&.api_keys&.last
|
||||
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
|
||||
setup_os = detect_setup_os(request.user_agent)
|
||||
PosthogService.capture(current_user, "setup_started", { step: 1 })
|
||||
|
||||
render inertia: "WakatimeSetup/Index", props: {
|
||||
current_user_api_key: api_key&.token,
|
||||
setup_os: setup_os.to_s,
|
||||
current_user_api_key: api_key.token,
|
||||
setup_os: detect_setup_os(request.user_agent).to_s,
|
||||
api_url: api_hackatime_v1_url,
|
||||
heartbeat_check_url: api_v1_my_heartbeats_most_recent_path(source_type: "test_entry")
|
||||
}
|
||||
end
|
||||
|
||||
def wakatime_setup_step_2
|
||||
PosthogService.capture(current_user, "setup_step_viewed", { step: 2 })
|
||||
|
||||
render inertia: "WakatimeSetup/Step2", props: {}
|
||||
end
|
||||
|
||||
def wakatime_setup_step_3
|
||||
api_key = current_user&.api_keys&.last
|
||||
api_key ||= current_user.api_keys.create!(name: "Wakatime API Key")
|
||||
editor = params[:editor]
|
||||
PosthogService.capture(current_user, "setup_step_viewed", { step: 3 })
|
||||
|
||||
render inertia: "WakatimeSetup/Step3", props: {
|
||||
current_user_api_key: api_key&.token,
|
||||
editor: editor,
|
||||
current_user_api_key: api_key.token,
|
||||
editor: params[:editor],
|
||||
heartbeat_check_url: api_v1_my_heartbeats_most_recent_path
|
||||
}
|
||||
end
|
||||
|
||||
def wakatime_setup_step_4
|
||||
PosthogService.capture(current_user, "setup_completed", { step: 4 })
|
||||
|
||||
render inertia: "WakatimeSetup/Step4", props: {
|
||||
dino_video_url: FlavorText.dino_meme_videos.sample,
|
||||
return_url: session.dig(:return_data, "url"),
|
||||
|
|
|
|||
|
|
@ -12,19 +12,28 @@
|
|||
action?: string;
|
||||
};
|
||||
|
||||
type AdminLevel = "default" | "superadmin" | "admin" | "viewer";
|
||||
|
||||
type NavCurrentUser = {
|
||||
display_name: string;
|
||||
avatar_url?: string | null;
|
||||
title: string;
|
||||
country_code?: string | null;
|
||||
country_name?: string | null;
|
||||
streak_days?: number | null;
|
||||
admin_level: AdminLevel;
|
||||
};
|
||||
|
||||
type LayoutNav = {
|
||||
flash: { message: string; class_name: string }[];
|
||||
user_present: boolean;
|
||||
user_mention_html?: string | null;
|
||||
streak_html?: string | null;
|
||||
admin_level_html?: string | null;
|
||||
current_user?: NavCurrentUser | null;
|
||||
login_path: string;
|
||||
links: NavLink[];
|
||||
dev_links: NavLink[];
|
||||
admin_links: NavLink[];
|
||||
viewer_links: NavLink[];
|
||||
superadmin_links: NavLink[];
|
||||
activities_html?: string | null;
|
||||
};
|
||||
|
||||
type Footer = {
|
||||
|
|
@ -38,7 +47,7 @@
|
|||
cache_hits: number;
|
||||
cache_misses: number;
|
||||
requests_per_second: string;
|
||||
active_users_graph: { height: number; title: string }[];
|
||||
active_users_graph: { height: number; users: number }[];
|
||||
};
|
||||
|
||||
type CurrentlyHackingUser = {
|
||||
|
|
@ -112,6 +121,83 @@
|
|||
)
|
||||
: "";
|
||||
|
||||
const latinPhrases = [
|
||||
"carpe diem",
|
||||
"nemo sine vitio est",
|
||||
"docendo discimus",
|
||||
"per aspera ad astra",
|
||||
"ex nihilo nihil",
|
||||
"aut viam inveniam aut faciam",
|
||||
"semper ad mellora",
|
||||
"soli fortes, una fortiores",
|
||||
"nulla tenaci invia est via",
|
||||
"nihil boni sine labore",
|
||||
];
|
||||
|
||||
const activeUsersGraphTitle = (hourIndex: number, users: number) => {
|
||||
const hoursAgo = hourIndex + 1;
|
||||
const phrase = latinPhrases[(hoursAgo + users) % latinPhrases.length];
|
||||
return `${hoursAgo} ${plur("hour", hoursAgo)} ago, ${users} ${plur("person", users)} logged time. '${phrase}.'`;
|
||||
};
|
||||
|
||||
const countryFlagEmoji = (countryCode?: string | null) => {
|
||||
if (!countryCode) return "";
|
||||
return countryCode
|
||||
.toUpperCase()
|
||||
.replace(/./g, (char) =>
|
||||
String.fromCodePoint(127397 + char.charCodeAt(0)),
|
||||
);
|
||||
};
|
||||
|
||||
const streakThemeClasses = (streakDays: number) => {
|
||||
if (streakDays >= 30) {
|
||||
return {
|
||||
bg: "from-blue-900/20 to-indigo-900/20",
|
||||
hbg: "hover:from-blue-800/30 hover:to-indigo-800/30",
|
||||
bc: "border-blue-700",
|
||||
ic: "text-blue-400 group-hover:text-blue-300",
|
||||
tc: "text-blue-300 group-hover:text-blue-200",
|
||||
tm: "text-blue-400",
|
||||
};
|
||||
}
|
||||
|
||||
if (streakDays >= 7) {
|
||||
return {
|
||||
bg: "from-red-900/20 to-orange-900/20",
|
||||
hbg: "hover:from-red-800/30 hover:to-orange-800/30",
|
||||
bc: "border-red-700",
|
||||
ic: "text-red-400 group-hover:text-red-300",
|
||||
tc: "text-red-300 group-hover:text-red-200",
|
||||
tm: "text-red-400",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bg: "from-orange-900/20 to-yellow-900/20",
|
||||
hbg: "hover:from-orange-800/30 hover:to-yellow-800/30",
|
||||
bc: "border-orange-700",
|
||||
ic: "text-orange-400 group-hover:text-orange-300",
|
||||
tc: "text-orange-300 group-hover:text-orange-200",
|
||||
tm: "text-orange-400",
|
||||
};
|
||||
};
|
||||
|
||||
const streakLabel = (streakDays: number) => (streakDays > 30 ? "30+" : `${streakDays}`);
|
||||
|
||||
const adminLevelLabel = (adminLevel?: AdminLevel | null) => {
|
||||
if (adminLevel === "superadmin") return "Superadmin";
|
||||
if (adminLevel === "admin") return "Admin";
|
||||
if (adminLevel === "viewer") return "Viewer";
|
||||
return null;
|
||||
};
|
||||
|
||||
const adminLevelClass = (adminLevel?: AdminLevel | null) => {
|
||||
if (adminLevel === "superadmin") return "text-red-500 superadmin-tool";
|
||||
if (adminLevel === "admin") return "text-yellow-500 admin-tool";
|
||||
if (adminLevel === "viewer") return "text-blue-500 viewer-tool";
|
||||
return "";
|
||||
};
|
||||
|
||||
const toggleCurrentlyHacking = () => {
|
||||
currentlyExpanded = !currentlyExpanded;
|
||||
};
|
||||
|
|
@ -211,11 +297,65 @@
|
|||
<div
|
||||
class="flex flex-col items-center gap-2 pb-3 border-b border-darkless"
|
||||
>
|
||||
{#if layout.nav.user_mention_html}{@html layout.nav
|
||||
.user_mention_html}{/if}
|
||||
{#if layout.nav.streak_html}{@html layout.nav.streak_html}{/if}
|
||||
{#if layout.nav.admin_level_html}{@html layout.nav
|
||||
.admin_level_html}{/if}
|
||||
{#if layout.nav.current_user}
|
||||
<div class="user-info flex items-center gap-2" title={layout.nav.current_user.title}>
|
||||
{#if layout.nav.current_user.avatar_url}
|
||||
<img
|
||||
src={layout.nav.current_user.avatar_url}
|
||||
alt={`${layout.nav.current_user.display_name}'s avatar`}
|
||||
width="32"
|
||||
height="32"
|
||||
class="rounded-full aspect-square border border-gray-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/if}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
{layout.nav.current_user.display_name}
|
||||
</span>
|
||||
{#if layout.nav.current_user.country_code}
|
||||
<span
|
||||
class="flex items-center"
|
||||
title={layout.nav.current_user.country_name || layout.nav.current_user.country_code}
|
||||
>
|
||||
{countryFlagEmoji(layout.nav.current_user.country_code)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if layout.nav.current_user.streak_days && layout.nav.current_user.streak_days > 0}
|
||||
{@const streakTheme = streakThemeClasses(layout.nav.current_user.streak_days)}
|
||||
<div
|
||||
class={`inline-flex items-center gap-1 px-2 py-1 bg-gradient-to-r ${streakTheme.bg} border ${streakTheme.bc} rounded-lg transition-all duration-200 ${streakTheme.hbg} group`}
|
||||
title={layout.nav.current_user.streak_days > 30 ? "30+ daily streak" : `${layout.nav.current_user.streak_days} day streak`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
class={`${streakTheme.ic} transition-colors duration-200 group-hover:animate-pulse`}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10 2c0-.88 1.056-1.331 1.692-.722c1.958 1.876 3.096 5.995 1.75 9.12l-.08.174l.012.003c.625.133 1.203-.43 2.303-2.173l.14-.224a1 1 0 0 1 1.582-.153C18.733 9.46 20 12.402 20 14.295C20 18.56 16.409 22 12 22s-8-3.44-8-7.706c0-2.252 1.022-4.716 2.632-6.301l.605-.589c.241-.236.434-.43.618-.624C9.285 5.268 10 3.856 10 2"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
<span class={`text-md font-semibold ${streakTheme.tc} transition-colors duration-200`}>
|
||||
{streakLabel(layout.nav.current_user.streak_days)}
|
||||
<span class={`ml-1 font-normal ${streakTheme.tm}`}>day streak</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if adminLevelLabel(layout.nav.current_user.admin_level)}
|
||||
<span
|
||||
class={`${adminLevelClass(layout.nav.current_user.admin_level)} font-semibold px-2`}
|
||||
>
|
||||
{adminLevelLabel(layout.nav.current_user.admin_level)}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
|
|
@ -315,10 +455,6 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if layout.nav.activities_html}
|
||||
<div class="pt-2">{@html layout.nav.activities_html}</div>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -361,10 +497,10 @@
|
|||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 mt-4 justify-center">
|
||||
{#each layout.footer.active_users_graph as hour}
|
||||
{#each layout.footer.active_users_graph as hour, hourIndex}
|
||||
<div
|
||||
class="bg-white opacity-10 grow max-w-1 rounded-sm"
|
||||
title={hour.title}
|
||||
title={activeUsersGraphTitle(hourIndex, hour.users)}
|
||||
style={`height: ${hour.height}px`}
|
||||
></div>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -76,17 +76,42 @@
|
|||
let todayStats = $state<TodayStats | null>(null);
|
||||
let dashboardData = $state<FilterableDashboardData | null>(null);
|
||||
let activityGraph = $state<ActivityGraphData | null>(null);
|
||||
let requestSequence = 0;
|
||||
|
||||
onMount(async () => {
|
||||
function buildDashboardStatsUrl(search: string) {
|
||||
const url = new URL(dashboard_stats_url, window.location.origin);
|
||||
if (search.startsWith("?")) {
|
||||
url.search = search;
|
||||
} else if (search) {
|
||||
url.search = `?${search}`;
|
||||
} else {
|
||||
url.search = "";
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
async function refreshDashboardData(search: string) {
|
||||
const requestId = ++requestSequence;
|
||||
try {
|
||||
const res = await fetch(dashboard_stats_url, {
|
||||
const res = await fetch(buildDashboardStatsUrl(search), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
if (requestId !== requestSequence) return;
|
||||
|
||||
todayStats = data.today_stats;
|
||||
dashboardData = data.filterable_dashboard_data;
|
||||
activityGraph = data.activity_graph;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await refreshDashboardData(window.location.search);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
|
@ -143,7 +168,7 @@
|
|||
{#if loading}
|
||||
<DashboardSkeleton />
|
||||
{:else if dashboardData}
|
||||
<Dashboard data={dashboardData} />
|
||||
<Dashboard data={dashboardData} onFiltersChange={refreshDashboardData} />
|
||||
{/if}
|
||||
|
||||
<!-- Activity Graph -->
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { router } from "@inertiajs/svelte";
|
||||
import { secondsToDisplay } from "./utils";
|
||||
import StatCard from "./StatCard.svelte";
|
||||
import HorizontalBarList from "./HorizontalBarList.svelte";
|
||||
|
|
@ -10,8 +9,10 @@
|
|||
|
||||
let {
|
||||
data,
|
||||
onFiltersChange,
|
||||
}: {
|
||||
data: Record<string, any>;
|
||||
onFiltersChange?: (search: string) => Promise<void> | void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
|
|
@ -35,7 +36,7 @@
|
|||
const capitalize = (s: string) =>
|
||||
s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
|
||||
|
||||
function applyFilters(overrides: Record<string, string>) {
|
||||
async function applyFilters(overrides: Record<string, string>) {
|
||||
const current = new URL(window.location.href);
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v) {
|
||||
|
|
@ -45,29 +46,26 @@
|
|||
}
|
||||
}
|
||||
|
||||
window.history.pushState({}, "", current.pathname + current.search);
|
||||
|
||||
loading = true;
|
||||
router.get(
|
||||
current.pathname + current.search,
|
||||
{},
|
||||
{
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
only: ["filterable_dashboard_data"],
|
||||
onFinish: () => (loading = false),
|
||||
},
|
||||
);
|
||||
try {
|
||||
await onFiltersChange?.(current.search);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onIntervalChange(interval: string, from: string, to: string) {
|
||||
if (from || to) {
|
||||
applyFilters({ interval: "custom", from, to });
|
||||
void applyFilters({ interval: "custom", from, to });
|
||||
} else {
|
||||
applyFilters({ interval, from: "", to: "" });
|
||||
void applyFilters({ interval, from: "", to: "" });
|
||||
}
|
||||
}
|
||||
|
||||
function onFilterChange(param: string, selected: string[]) {
|
||||
applyFilters({ [param]: selected.join(",") });
|
||||
void applyFilters({ [param]: selected.join(",") });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -51,22 +51,11 @@
|
|||
})),
|
||||
);
|
||||
|
||||
const legendClasses = {
|
||||
root: "w-full px-2",
|
||||
swatches: "flex-wrap justify-center",
|
||||
label: "text-xs text-white/70",
|
||||
};
|
||||
|
||||
const legendPadding = $derived.by(() => {
|
||||
const rows = Math.max(1, Math.ceil(series.length / 4));
|
||||
return Math.min(120, 24 + rows * 18);
|
||||
});
|
||||
|
||||
const chartPadding = $derived.by(() => ({
|
||||
top: 4,
|
||||
right: 4,
|
||||
left: 20,
|
||||
bottom: 20 + legendPadding,
|
||||
bottom: 20,
|
||||
}));
|
||||
|
||||
function formatDuration(value: number): string {
|
||||
|
|
@ -102,11 +91,9 @@
|
|||
x="week"
|
||||
{series}
|
||||
seriesLayout="stack"
|
||||
legend
|
||||
padding={chartPadding}
|
||||
props={{
|
||||
yAxis: { format: formatYAxis },
|
||||
legend: { classes: legendClasses },
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="tooltip">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
|
||||
<div
|
||||
class={`
|
||||
relative flex flex-col justify-between p-4 pb-6 rounded-xl border transition-all duration-200 h-full
|
||||
relative flex flex-col justify-start p-4 rounded-xl border transition-all duration-200 h-full
|
||||
${subtitle ? "pb-6" : ""}
|
||||
${
|
||||
highlight
|
||||
? "bg-primary/10 border-primary/30"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
{ id: 'vim', name: 'Vim', icon: '/images/editor-icons/vim-128.png' },
|
||||
{ id: 'neovim', name: 'Neovim', icon: '/images/editor-icons/neovim-128.png' },
|
||||
{ id: 'emacs', name: 'Emacs', icon: '/images/editor-icons/emacs-128.png' },
|
||||
{ id: 'pycharm', name: 'PyCharm', icon: '/images/editor-icons/pycharm-128.png' },
|
||||
{ id: 'jetbrains', name: 'JetBrains', icon: '/images/editor-icons/jetbrains-128.png' },
|
||||
{ id: 'sublime', name: 'Sublime', icon: '/images/editor-icons/sublime-text-128.png' },
|
||||
{ id: 'unity', name: 'Unity', icon: '/images/editor-icons/unity-128.png' },
|
||||
{ id: 'godot', name: 'Godot', icon: '/images/editor-icons/godot-128.png' },
|
||||
|
|
|
|||
|
|
@ -222,10 +222,10 @@
|
|||
</summary>
|
||||
<div class="mt-4 pl-6">
|
||||
<p class="text-sm mb-3 text-secondary">
|
||||
You'll see a clock icon in your status bar:
|
||||
You'll see a clock icon and time spent coding in your status bar:
|
||||
</p>
|
||||
<img
|
||||
src="https://hc-cdn.hel1.your-objectstorage.com/s/v3/95d2513ce4b0c1c147827d17ecb4c24540cd73cc_p.png"
|
||||
src="/images/editor-toolbars/vs-code.png"
|
||||
alt="WakaTime status bar"
|
||||
class="rounded-lg border border-darkless"
|
||||
/>
|
||||
|
|
@ -347,6 +347,225 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/my/wakatime_setup/step-4"
|
||||
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
|
||||
>
|
||||
Next Step
|
||||
</a>
|
||||
{:else if editor === "jetbrains"}
|
||||
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src="/images/editor-icons/jetbrains-128.png"
|
||||
alt="JetBrains"
|
||||
class="w-12 h-12 object-contain"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">Set Up JetBrains IDEs</h3>
|
||||
<p class="text-secondary text-sm">
|
||||
Install the WakaTime extension for JetBrains IDEs (like IntelliJ and PyCharm).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm">
|
||||
JetBrains IDEs require a plugin installed for each IDE separately.
|
||||
</p>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium mb-1">Open Settings</p>
|
||||
<p class="text-sm text-secondary">
|
||||
Open your IDE and go to <b>Settings</b> (Ctrl+Alt+S on Windows/Linux, Command+, on macOS), <b>Plugins</b>, then <b>Marketplace</b>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium mb-1">Install WakaTime Plugin</p>
|
||||
<p class="text-sm text-secondary">
|
||||
Search for <b>WakaTime</b> in the marketplace and click Install.
|
||||
|
||||
<a
|
||||
href="https://plugins.jetbrains.com/plugin/7425-wakatime"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-cyan hover:underline">View on Marketplace</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium mb-1">Restart & Code</p>
|
||||
<p class="text-sm text-secondary">
|
||||
Restart your IDE if prompted. Then, open any file and start
|
||||
typing to send your first heartbeat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-darkless">
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer text-sm text-secondary hover:text-white flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
How do I know it's working?
|
||||
</summary>
|
||||
<div class="mt-4 pl-6">
|
||||
<p class="text-sm mb-3 text-secondary">
|
||||
You'll see a WakaTime icon and time spent coding in your status bar:
|
||||
</p>
|
||||
<img
|
||||
src="/images/editor-toolbars/jetbrains.png"
|
||||
alt="WakaTime status bar"
|
||||
class="rounded-lg border border-darkless"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/my/wakatime_setup/step-4"
|
||||
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
|
||||
>
|
||||
Next Step
|
||||
</a>
|
||||
{:else if editor === "sublime"}
|
||||
<div class="bg-dark border border-darkless rounded-xl p-8 shadow-sm mb-8">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src="/images/editor-icons/sublime-text-128.png"
|
||||
alt="Sublime Text"
|
||||
class="w-12 h-12 object-contain"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold">Set Up Sublime Text</h3>
|
||||
<p class="text-secondary text-sm">
|
||||
Use Package Control to install WakaTime for Sublime Text.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium mb-1">Install Package Control</p>
|
||||
<p class="text-sm text-secondary">
|
||||
If you don't have Package Control installed, install it at
|
||||
<a
|
||||
href="https://packagecontrol.io/installation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-cyan hover:underline">packagecontrol.io</a
|
||||
> to set it up first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium mb-1">Install WakaTime Plugin</p>
|
||||
<p class="text-sm text-secondary">
|
||||
Open the Command Palette (Ctrl+Shift+P on Windows/Linux, Command+Shift+P on macOS), type <b>Package Control: Install Package</b>, and press Enter. Then type <b>WakaTime</b> and press Enter to install.
|
||||
<a
|
||||
href="https://packagecontrol.io/packages/WakaTime"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-cyan hover:underline">View on Package Control</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full bg-darkless text-white flex items-center justify-center text-xs font-bold mt-0.5"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium mb-1">Start Coding</p>
|
||||
<p class="text-sm text-secondary">
|
||||
After installing WakaTime, open any file and start typing to send your first heartbeat.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-darkless">
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer text-sm text-secondary hover:text-white flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 transition-transform group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
How do I know it's working?
|
||||
</summary>
|
||||
<div class="mt-4 pl-6">
|
||||
<p class="text-sm mb-3 text-secondary">
|
||||
You'll see your time spent coding in your status bar, which looks something like <code>Today: 1h 23m</code>.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/my/wakatime_setup/step-4"
|
||||
class="inline-flex items-center justify-center bg-primary hover:bg-primary/90 text-white px-8 py-3 rounded-lg font-semibold w-full"
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
class CreateHeartbeatActivityJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(user_id, project_name)
|
||||
@user_id = user_id
|
||||
|
||||
# Look for future coding activity only (not past events that are already showing)
|
||||
recent_activity = PublicActivity::Activity.with_future
|
||||
.where(owner_id: user_id, trackable_type: "User", key: "user.coding_session")
|
||||
.where("created_at > ?", Time.current)
|
||||
.first
|
||||
|
||||
if recent_activity
|
||||
# Keep pushing 5 minutes into future and update project/timing
|
||||
last_updated = Time.current.to_i
|
||||
started_at = recent_activity.parameters["started_at"]
|
||||
duration_seconds = last_updated - started_at
|
||||
|
||||
recent_activity.update!(
|
||||
created_at: Time.current + 5.minutes,
|
||||
parameters: recent_activity.parameters.merge(
|
||||
project: project_name,
|
||||
last_updated: last_updated,
|
||||
duration_seconds: duration_seconds
|
||||
)
|
||||
)
|
||||
else
|
||||
return unless user
|
||||
|
||||
# Create immediate "started working" activity - person just resumed coding
|
||||
PublicActivity::Activity.create!(
|
||||
trackable: user,
|
||||
owner: user,
|
||||
key: "user.started_working",
|
||||
parameters: { project: project_name }
|
||||
)
|
||||
|
||||
# Create new session 5 minutes in future
|
||||
started_at = Time.current.to_i
|
||||
activity = PublicActivity::Activity.create!(
|
||||
trackable: user,
|
||||
owner: user,
|
||||
key: "user.coding_session",
|
||||
parameters: {
|
||||
project: project_name,
|
||||
started_at: started_at,
|
||||
last_updated: started_at,
|
||||
duration_seconds: 0
|
||||
},
|
||||
created_at: Time.current + 5.minutes
|
||||
)
|
||||
|
||||
# Check if this is the user's first heartbeat ever
|
||||
if user.heartbeats.count == 1
|
||||
PublicActivity::Activity.create!(
|
||||
trackable: user,
|
||||
owner: user,
|
||||
key: "user.first_heartbeat",
|
||||
parameters: { project: project_name }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user
|
||||
@user ||= User.find_by(id: @user_id)
|
||||
end
|
||||
end
|
||||
|
|
@ -3,7 +3,6 @@ class Heartbeat < ApplicationRecord
|
|||
|
||||
include Heartbeatable
|
||||
include TimeRangeFilterable
|
||||
include PublicActivity::Common
|
||||
|
||||
time_range_filterable_field :time
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
class User < ApplicationRecord
|
||||
include TimezoneRegions
|
||||
include PublicActivity::Model
|
||||
include ::OauthAuthentication
|
||||
include ::SlackIntegration
|
||||
include ::GithubIntegration
|
||||
|
|
@ -9,7 +8,7 @@ class User < ApplicationRecord
|
|||
|
||||
has_paper_trail
|
||||
|
||||
after_create :create_signup_activity
|
||||
after_create :track_signup
|
||||
before_validation :normalize_username
|
||||
encrypts :slack_access_token, :github_access_token, :hca_access_token
|
||||
|
||||
|
|
@ -305,8 +304,9 @@ class User < ApplicationRecord
|
|||
Rails.cache.delete("user_#{id}_daily_durations")
|
||||
end
|
||||
|
||||
def create_signup_activity
|
||||
create_activity :first_signup, owner: self
|
||||
def track_signup
|
||||
PosthogService.identify(self)
|
||||
PosthogService.capture(self, "account_created", { source: "signup" })
|
||||
end
|
||||
|
||||
def normalize_username
|
||||
|
|
|
|||
47
app/services/posthog_service.rb
Normal file
47
app/services/posthog_service.rb
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
class PosthogService
|
||||
class << self
|
||||
def capture(user_or_id, event, properties = {})
|
||||
return unless $posthog
|
||||
|
||||
distinct_id = user_or_id.is_a?(User) ? user_or_id.id.to_s : user_or_id.to_s
|
||||
|
||||
$posthog.capture(
|
||||
distinct_id: distinct_id,
|
||||
event: event,
|
||||
properties: properties
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error "PostHog capture error: #{e.message}"
|
||||
end
|
||||
|
||||
def identify(user, properties = {})
|
||||
return unless $posthog
|
||||
|
||||
$posthog.identify(
|
||||
distinct_id: user.id.to_s,
|
||||
properties: {
|
||||
slack_uid: user.slack_uid,
|
||||
username: user.username,
|
||||
timezone: user.timezone,
|
||||
country_code: user.country_code,
|
||||
created_at: user.created_at&.iso8601,
|
||||
admin_level: user.admin_level
|
||||
}.merge(properties)
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.error "PostHog identify error: #{e.message}"
|
||||
end
|
||||
|
||||
def capture_once_per_day(user, event, properties = {})
|
||||
return unless $posthog
|
||||
|
||||
cache_key = "posthog_daily:#{user.id}:#{event}:#{Date.current}"
|
||||
return if Rails.cache.exist?(cache_key)
|
||||
|
||||
capture(user, event, properties)
|
||||
Rails.cache.write(cache_key, true, expires_at: Date.current.end_of_day + 1.hour)
|
||||
rescue => e
|
||||
Rails.logger.error "PostHog capture_once_per_day error: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -161,6 +161,26 @@
|
|||
|
||||
<script defer data-domain="hackatime.hackclub.com" src="https://plausible.io/js/script.file-downloads.hash.js"></script>
|
||||
|
||||
<% if ENV['POSTHOG_API_KEY'].present? %>
|
||||
<script>
|
||||
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace('.i.posthog.com','-assets.i.posthog.com')+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId setPersonProperties".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
posthog.init('<%= ENV['POSTHOG_API_KEY'] %>', {
|
||||
api_host: '<%= ENV.fetch('POSTHOG_HOST', 'https://us.i.posthog.com') %>',
|
||||
person_profiles: 'identified_only',
|
||||
capture_pageview: true,
|
||||
capture_pageleave: true,
|
||||
autocapture: true
|
||||
});
|
||||
<% if current_user %>
|
||||
posthog.identify('<%= current_user.id %>', {
|
||||
slack_uid: '<%= current_user.slack_uid %>',
|
||||
username: '<%= current_user.username %>',
|
||||
timezone: '<%= current_user.timezone %>'
|
||||
});
|
||||
<% end %>
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= stylesheet_link_tag :app %>
|
||||
<%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,48 @@
|
|||
<div class="max-w-6xl mx-auto px-3 py-4 sm:p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-4">Leaderboard</h1>
|
||||
<div class="mb-8 space-y-4">
|
||||
<h1 class="text-3xl font-bold text-white">Leaderboards</h1>
|
||||
|
||||
<div class="inline-flex rounded-full p-1 mb-4">
|
||||
<%= link_to 'Last 24 Hours', leaderboards_path(period_type: 'daily'), class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@period_type == :daily ? 'bg-primary text-white' : 'text-muted bg-darkless hover:text-white'}" %>
|
||||
<%= link_to 'Last 7 Days', leaderboards_path(period_type: 'last_7_days'), class: "px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 #{@period_type == :last_7_days ? 'bg-primary text-white' : 'text-muted bg-darkless hover:text-white'}" %>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1">
|
||||
<%= link_to 'Global', leaderboards_path(period_type: @period_type, scope: 'global'), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@leaderboard_scope == :global ? 'bg-primary text-white' : 'text-muted hover:text-white'}" %>
|
||||
|
||||
<% if @country_scope_available %>
|
||||
<%= link_to leaderboards_path(period_type: @period_type, scope: 'country'), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 inline-flex items-center justify-center gap-2 whitespace-nowrap #{@leaderboard_scope == :country ? 'bg-primary text-white' : 'text-muted hover:text-white'}" do %>
|
||||
<%= country_to_emoji(@country_code) %>
|
||||
<span class="max-w-[12rem] truncate"><%= @country_name %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-center px-4 py-2 rounded-full text-sm font-medium text-muted/60 bg-darker cursor-not-allowed whitespace-nowrap">Country</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1">
|
||||
<%= link_to 'Last 24 Hours', leaderboards_path(period_type: 'daily', scope: @leaderboard_scope), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@period_type == :daily ? 'bg-primary text-white' : 'text-muted hover:text-white'}" %>
|
||||
<%= link_to 'Last 7 Days', leaderboards_path(period_type: 'last_7_days', scope: @leaderboard_scope), class: "text-center px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 whitespace-nowrap #{@period_type == :last_7_days ? 'bg-primary text-white' : 'text-muted hover:text-white'}" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if current_user && !@country_scope_available %>
|
||||
<p class="text-xs text-muted">
|
||||
Set your country in
|
||||
<%= link_to 'settings', my_settings_path, class: 'text-accent hover:text-cyan-400 transition-colors' %>
|
||||
to unlock regional leaderboards.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if current_user && current_user.github_uid.blank? %>
|
||||
<div class="bg-darker border border-primary rounded-lg p-4 mb-6 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div class="bg-darker border border-primary rounded-lg p-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<span class="text-white">Connect your GitHub to qualify for the leaderboard.</span>
|
||||
<%= link_to 'Connect GitHub', '/auth/github', class: 'bg-primary hover:bg-primary/75 text-white font-medium px-4 py-2 rounded-lg transition-colors duration-200 text-center shrink-0 w-fit' %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="text-muted text-sm">
|
||||
<div class="text-muted text-sm flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<% if @leaderboard %>
|
||||
<%= @leaderboard.date_range_text %>
|
||||
|
||||
<% if @leaderboard.finished_generating? && @leaderboard.persisted? %>
|
||||
<span class="italic"> - Updated <%= time_ago_in_words(@leaderboard.updated_at) %> ago. </span>
|
||||
<span class="italic">• Updated <%= time_ago_in_words(@leaderboard.updated_at) %> ago.</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%=
|
||||
|
|
@ -37,16 +60,21 @@
|
|||
<div class="bg-elevated rounded-xl border border-primary overflow-hidden">
|
||||
<% if @leaderboard&.persisted? %>
|
||||
<% if @total_entries && @total_entries > 0 %>
|
||||
<div id="leaderboard-entries" class="divide-y divide-gray-800" data-controller="infinite-scroll" data-infinite-scroll-url-value="<%= entries_leaderboards_path(period_type: @period_type) %>" data-infinite-scroll-page-value="1" data-infinite-scroll-total-value="<%= @total_entries %>">
|
||||
<div id="leaderboard-entries" class="divide-y divide-gray-800" data-controller="infinite-scroll" data-infinite-scroll-url-value="<%= entries_leaderboards_path(period_type: @period_type, scope: @leaderboard_scope) %>" data-infinite-scroll-page-value="1" data-infinite-scroll-total-value="<%= @total_entries %>">
|
||||
<%= render 'loading' %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="py-16 text-center">
|
||||
<div class="py-16 text-center px-3">
|
||||
<h3 class="text-xl font-medium text-white mb-2">No data available</h3>
|
||||
<p class="text-muted">Check back later for <%= @period_type == :last_7_days ? 'the last 7 days' : 'the last 24 hours' %> results!</p>
|
||||
<p class="text-muted">
|
||||
Check back later for
|
||||
<%= @period_type == :last_7_days ? 'last 7 days' : 'last 24 hours' %>
|
||||
results!
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% unless @user_on_leaderboard && @untracked_entries != 0 %>
|
||||
|
||||
<% if @leaderboard_scope == :global && @untracked_entries.to_i.positive? && !@user_on_leaderboard %>
|
||||
<div class="px-4 py-3 text-sm text-muted border-t border-primary">
|
||||
Don't see yourself on the leaderboard? You're probably one of the
|
||||
<%= pluralize(@untracked_entries, 'user') %>
|
||||
|
|
@ -54,13 +82,19 @@
|
|||
<%= link_to 'updated their wakatime config', my_settings_path, target: '_blank', class: 'text-accent hover:text-cyan-400 transition-colors' %>.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @leaderboard.finished_generating? %>
|
||||
<div class="px-4 py-2 text-xs italic text-muted border-t border-primary">Generated in <%= @leaderboard.finished_generating_at - @leaderboard.created_at %> seconds</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="py-16 text-center">
|
||||
<div class="py-16 text-center px-3">
|
||||
<h3 class="text-xl font-medium text-white mb-2">No data available</h3>
|
||||
<p class="text-muted">Check back later for <%= @period_type == :last_7_days ? 'the last 7 days' : 'the last 24 hours' %> results!</p>
|
||||
<p class="text-muted">
|
||||
Check back later for
|
||||
<%= @leaderboard_scope == :country ? "#{@country_name} " : '' %>
|
||||
<%= @period_type == :last_7_days ? 'last 7 days' : 'last 24 hours' %>
|
||||
results!
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
<%= render 'shared/user_mention', user: activity.owner %>
|
||||
just hit their first 7 day coding streak!
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<%= render 'shared/user_mention', user: activity.owner %>
|
||||
was just sent a letter for '<%= activity.parameters[:humanized_mission_type] %>'
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<%= render 'shared/user_mention', user: activity.owner %>
|
||||
just finished coding on <%= activity.parameters['project'] %> for <%= short_time_simple(activity.parameters['duration_seconds']) %>
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<%= render 'shared/user_mention', user: activity.owner %>
|
||||
just started tracking their coding time on <%= activity.parameters['project'] %>!
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<%= render 'shared/user_mention', user: activity.owner %>
|
||||
just signed in for the first time
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
<%= render 'shared/user_mention', user: activity.owner %>
|
||||
just started working on <%= activity.parameters['project'] %>
|
||||
|
|
@ -188,12 +188,6 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if defined?(@activities) %>
|
||||
<div class="pt-2">
|
||||
<%= render_activities(@activities) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -370,7 +370,7 @@
|
|||
</div>
|
||||
<div class="bg-darkless border border-darkless rounded p-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-orange mb-1"><%= @user.heartbeats.duration_simple %></div>
|
||||
<div class="text-2xl font-bold text-orange mb-1"><%= number_with_delimiter(@user.heartbeats.duration_simple) %></div>
|
||||
<div class="text-sm text-gray-300">Total Coding Time</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
16
bun.lock
16
bun.lock
|
|
@ -91,9 +91,9 @@
|
|||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@inertiajs/core": ["@inertiajs/core@2.3.13", "", { "dependencies": { "@types/lodash-es": "^4.17.12", "axios": "^1.13.2", "laravel-precognition": "^1.0.1", "lodash-es": "^4.17.23", "qs": "^6.14.1" } }, "sha512-qMHRnb59k/HehXw/WfQt5kPV0k9RapfFcWJZINJnYMwfHDEJ21iNVZjsJHmDN7yWdZmG1Dxi9FP4xarWWgdosQ=="],
|
||||
"@inertiajs/core": ["@inertiajs/core@2.3.15", "", { "dependencies": { "@types/lodash-es": "^4.17.12", "axios": "^1.13.5", "laravel-precognition": "^1.0.2", "lodash-es": "^4.17.23", "qs": "^6.14.2" } }, "sha512-C/x5w2/VhPpzfCg7SCtOxRrC8yKM7zIMvwpberMLrvSLMqPqGTFxYTJH+0E//G03RXzb+Q3+eatepbSq6tpZGw=="],
|
||||
|
||||
"@inertiajs/svelte": ["@inertiajs/svelte@2.3.13", "", { "dependencies": { "@inertiajs/core": "2.3.13", "@types/lodash-es": "^4.17.12", "laravel-precognition": "^1.0.1", "lodash-es": "^4.17.23" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0" } }, "sha512-tKqAGn3FCdLA57bmZjm+26exVjZVQ0I15/KuoEofZKjZ8/4bndyHhhx79jmelZKlDNj4O3ECz15L5mHfo7YPSQ=="],
|
||||
"@inertiajs/svelte": ["@inertiajs/svelte@2.3.15", "", { "dependencies": { "@inertiajs/core": "2.3.15", "@types/lodash-es": "^4.17.12", "laravel-precognition": "^1.0.2", "lodash-es": "^4.17.23" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0" } }, "sha512-RFQYtVa1uZzzeh7c3nuHUQPj5fwCR138ShhXeoOYLj6tB4zXycIzuRmTKOSabwcgTGXHxfCkg77YfrCUb5Snig=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
|
|
@ -217,6 +217,8 @@
|
|||
|
||||
"@types/lodash-es": ["@types/lodash-es@4.17.12", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
|
@ -229,7 +231,7 @@
|
|||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
|
||||
"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=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
|
|
@ -491,7 +493,7 @@
|
|||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
||||
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
|
|
@ -529,9 +531,9 @@
|
|||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svelte": ["svelte@5.49.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "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-PYLwnngYzyhKzqDlGVlCH4z+NVI8mC0/bTv15vw25CcdOhxENsOHIbQ36oj5DIf3oBazM+STbCAvaskpxtBmWA=="],
|
||||
"svelte": ["svelte@5.51.1", "", { "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-r8r6p+NFC2ckAkxW4lqpGs1AZWBi5Y+TbJMmAglqSbokN5UWkDsKKkybfGBKXd8yYMri7KJ2L78fO9SO+NOelA=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.6", "", { "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-uBkz96ElE3G4pt9E1Tw0xvBfIUQkeH794kDQZdAUk795UVMr+NJZpuFSS62vcmO/DuSalK83LyOwhgWq8YGU1Q=="],
|
||||
"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=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@2.6.1", "", {}, "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ=="],
|
||||
|
||||
|
|
@ -585,6 +587,8 @@
|
|||
|
||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||
|
||||
"laravel-precognition/axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"postcss-nested/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=="],
|
||||
|
|
|
|||
|
|
@ -12,15 +12,7 @@ Rails.configuration.to_prepare do
|
|||
end
|
||||
|
||||
Doorkeeper::ApplicationsController.layout "application" # show oauth2 admin in normal hackatime ui
|
||||
|
||||
PublicActivity::Activity.class_eval do
|
||||
default_scope { where("created_at <= ?", Time.current) }
|
||||
scope :with_future, -> { unscope(where: :created_at) }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
class String
|
||||
# Hopefully this is the right place! It a really good monkey patch!!
|
||||
def categorize_language
|
||||
|
|
|
|||
11
config/initializers/posthog.rb
Normal file
11
config/initializers/posthog.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
require "posthog"
|
||||
|
||||
if ENV["POSTHOG_API_KEY"].present?
|
||||
$posthog = PostHog::Client.new({
|
||||
api_key: ENV["POSTHOG_API_KEY"],
|
||||
host: ENV.fetch("POSTHOG_HOST", "https://us.i.posthog.com"),
|
||||
on_error: proc { |status, msg| Rails.logger.error "PostHog error: #{status} - #{msg}" }
|
||||
})
|
||||
else
|
||||
$posthog = nil
|
||||
end
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
class AddHeartbeatIndexes < ActiveRecord::Migration[8.1]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :heartbeats, [ :user_id, :category, :time ],
|
||||
name: "idx_heartbeats_user_category_time",
|
||||
where: "(deleted_at IS NULL)",
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
|
||||
add_index :heartbeats, [ :user_id, :editor, :time ],
|
||||
name: "idx_heartbeats_user_editor_time",
|
||||
where: "(deleted_at IS NULL)",
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
|
||||
add_index :heartbeats, [ :user_id, :language, :time ],
|
||||
name: "idx_heartbeats_user_language_time",
|
||||
where: "(deleted_at IS NULL)",
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
|
||||
add_index :heartbeats, [ :user_id, :operating_system, :time ],
|
||||
name: "idx_heartbeats_user_operating_system_time",
|
||||
where: "(deleted_at IS NULL)",
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
|
||||
add_index :heartbeats, [ :user_id, :project ],
|
||||
name: "index_heartbeats_on_user_id_and_project",
|
||||
where: "(deleted_at IS NULL)",
|
||||
algorithm: :concurrently,
|
||||
if_not_exists: true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
class RemovePublicActivityLogFlipperFeature < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
execute <<~SQL
|
||||
DELETE FROM flipper_gates WHERE feature_key = 'public_activity_log';
|
||||
DELETE FROM flipper_features WHERE key = 'public_activity_log';
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
# no-op; this feature was intentionally removed
|
||||
end
|
||||
end
|
||||
21
db/migrate/20260215094000_drop_activities_table.rb
Normal file
21
db/migrate/20260215094000_drop_activities_table.rb
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
class DropActivitiesTable < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
drop_table :activities, if_exists: true
|
||||
end
|
||||
|
||||
def down
|
||||
create_table :activities do |t|
|
||||
t.belongs_to :trackable, polymorphic: true
|
||||
t.belongs_to :owner, polymorphic: true
|
||||
t.string :key
|
||||
t.text :parameters
|
||||
t.belongs_to :recipient, polymorphic: true
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :activities, %i[trackable_id trackable_type]
|
||||
add_index :activities, %i[owner_id owner_type]
|
||||
add_index :activities, %i[recipient_id recipient_type]
|
||||
end
|
||||
end
|
||||
21
db/schema.rb
generated
21
db/schema.rb
generated
|
|
@ -10,31 +10,12 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_02_10_094354) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_02_15_094000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_stat_statements"
|
||||
create_schema "pganalyze"
|
||||
|
||||
create_table "activities", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "key"
|
||||
t.bigint "owner_id"
|
||||
t.string "owner_type"
|
||||
t.text "parameters"
|
||||
t.bigint "recipient_id"
|
||||
t.string "recipient_type"
|
||||
t.bigint "trackable_id"
|
||||
t.string "trackable_type"
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["owner_id", "owner_type"], name: "index_activities_on_owner_id_and_owner_type"
|
||||
t.index ["owner_type", "owner_id"], name: "index_activities_on_owner"
|
||||
t.index ["recipient_id", "recipient_type"], name: "index_activities_on_recipient_id_and_recipient_type"
|
||||
t.index ["recipient_type", "recipient_id"], name: "index_activities_on_recipient"
|
||||
t.index ["trackable_id", "trackable_type"], name: "index_activities_on_trackable_id_and_trackable_type"
|
||||
t.index ["trackable_type", "trackable_id"], name: "index_activities_on_trackable"
|
||||
end
|
||||
|
||||
create_table "admin_api_keys", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.text "name", null: false
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ services:
|
|||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3036:3036"
|
||||
volumes:
|
||||
- .:/app
|
||||
- bundle_cache:/usr/local/bundle
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -6,21 +6,21 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@inertiajs/svelte": "^2.3.13",
|
||||
"@inertiajs/svelte": "^2.3.15",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tsconfig/svelte": "5",
|
||||
"@tsconfig/svelte": "^5.0.7",
|
||||
"d3-scale": "^4.0.2",
|
||||
"layerchart": "^1.0.13",
|
||||
"plur": "^6.0.0",
|
||||
"svelte": "5",
|
||||
"svelte-check": "^4.3.6",
|
||||
"svelte": "^5.51.1",
|
||||
"svelte-check": "^4.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-ruby": "^5.1.1"
|
||||
"vite-plugin-ruby": "^5.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
public/images/editor-icons/jetbrains-128.png
Normal file
BIN
public/images/editor-icons/jetbrains-128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/images/editor-toolbars/jetbrains.png
Normal file
BIN
public/images/editor-toolbars/jetbrains.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
BIN
public/images/editor-toolbars/vs-code.png
Normal file
BIN
public/images/editor-toolbars/vs-code.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
93
test/controllers/leaderboards_controller_test.rb
Normal file
93
test/controllers/leaderboards_controller_test.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
require "test_helper"
|
||||
|
||||
class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
teardown do
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
test "index renders country tab label and preserves scope in period links" do
|
||||
us_user = create_user(username: "us_index_user", country_code: "US")
|
||||
create_boards_for_today(period_type: :last_7_days)
|
||||
|
||||
sign_in_as(us_user)
|
||||
get leaderboards_path(period_type: "last_7_days", scope: "country")
|
||||
|
||||
assert_response :success
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "global")}']", text: "Global"
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "last_7_days", scope: "country")}']", text: /United States/
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "country")}']", text: "Last 24 Hours"
|
||||
end
|
||||
|
||||
test "index falls back to global selector state when country is missing" do
|
||||
viewer = create_user(username: "viewer_no_country")
|
||||
create_boards_for_today(period_type: :daily)
|
||||
|
||||
sign_in_as(viewer)
|
||||
get leaderboards_path(period_type: "daily", scope: "country")
|
||||
|
||||
assert_response :success
|
||||
assert_select "span", text: "Country"
|
||||
assert_select "a[href='#{leaderboards_path(period_type: "daily", scope: "global")}']"
|
||||
end
|
||||
|
||||
test "entries clamps page=0 to page 1 instead of erroring" do
|
||||
user = create_user(username: "page_zero_user")
|
||||
board = create_boards_for_today(period_type: :daily).first
|
||||
board.entries.create!(user: user, total_seconds: 100)
|
||||
|
||||
sign_in_as(user)
|
||||
get entries_leaderboards_path(period_type: "daily", page: 0), xhr: true
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "entries clamps negative page to page 1 instead of erroring" do
|
||||
user = create_user(username: "neg_page_user")
|
||||
board = create_boards_for_today(period_type: :daily).first
|
||||
board.entries.create!(user: user, total_seconds: 100)
|
||||
|
||||
sign_in_as(user)
|
||||
get entries_leaderboards_path(period_type: "daily", page: -5), xhr: true
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "validated_period_type does not intern arbitrary symbols" do
|
||||
user = create_user(username: "bad_period_user")
|
||||
create_boards_for_today(period_type: :daily)
|
||||
|
||||
sign_in_as(user)
|
||||
get leaderboards_path(period_type: "evil_user_input_xyz")
|
||||
|
||||
assert_response :success
|
||||
assert_not Symbol.all_symbols.map(&:to_s).include?("evil_user_input_xyz"),
|
||||
"Arbitrary user input should not be interned as a symbol"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_user(username:, country_code: nil)
|
||||
User.create!(username:, country_code:, timezone: "UTC")
|
||||
end
|
||||
|
||||
def create_boards_for_today(period_type:)
|
||||
[ Date.current, Time.current.in_time_zone("UTC").to_date ].uniq.map do |date|
|
||||
Leaderboard.create!(
|
||||
start_date: date,
|
||||
period_type: period_type,
|
||||
timezone_utc_offset: nil,
|
||||
finished_generating_at: Time.current
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in_as(user)
|
||||
token = user.sign_in_tokens.create!(auth_type: :email)
|
||||
get auth_token_path(token: token.token)
|
||||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
ActiveRecord::FixtureSet.reset_cache
|
||||
end
|
||||
|
||||
# -- HCA: hca_new stores continue in session --
|
||||
|
||||
test "hca_new stores continue path for oauth authorize" do
|
||||
continue_query = {
|
||||
client_id: "Ck47_6hihaBqZO7z3CLmJlCB-0NzHtZHGeDBwG4CqRs",
|
||||
|
|
@ -75,12 +77,9 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
# LoopsMailer forces SMTP delivery even in test; temporarily override
|
||||
original_delivery_method = LoopsMailer.delivery_method
|
||||
begin
|
||||
LoopsMailer.delivery_method = :test
|
||||
post email_auth_path, params: { email: "test@example.com", continue: oauth_path }
|
||||
ensure
|
||||
LoopsMailer.delivery_method = original_delivery_method
|
||||
end
|
||||
LoopsMailer.delivery_method = :test
|
||||
post email_auth_path, params: { email: "test@example.com", continue: oauth_path }
|
||||
LoopsMailer.delivery_method = original_delivery_method
|
||||
|
||||
assert_response :redirect
|
||||
|
||||
|
|
@ -89,9 +88,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_equal oauth_path, token.continue_param
|
||||
end
|
||||
|
||||
test "email token redirects user with heartbeats to continue param after sign in" do
|
||||
test "email token redirects to continue param after sign in" do
|
||||
user = User.create!
|
||||
user.heartbeats.create!(time: Time.current.to_f, source_type: :test_entry)
|
||||
oauth_path = "/oauth/authorize?client_id=test&response_type=code"
|
||||
sign_in_token = user.sign_in_tokens.create!(
|
||||
auth_type: :email,
|
||||
|
|
@ -105,25 +103,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "email token redirects user without heartbeats to wakatime setup with continue stored in session" do
|
||||
test "email token falls back to root when no continue param" do
|
||||
user = User.create!
|
||||
oauth_path = "/oauth/authorize?client_id=test&response_type=code"
|
||||
sign_in_token = user.sign_in_tokens.create!(
|
||||
auth_type: :email,
|
||||
continue_param: oauth_path
|
||||
)
|
||||
|
||||
get auth_token_path(token: sign_in_token.token)
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to my_wakatime_setup_path
|
||||
assert_equal user.id, session[:user_id]
|
||||
assert_equal oauth_path, session.dig(:return_data, "url")
|
||||
end
|
||||
|
||||
test "email token falls back to root when no continue param for user with heartbeats" do
|
||||
user = User.create!
|
||||
user.heartbeats.create!(time: Time.current.to_f, source_type: :test_entry)
|
||||
sign_in_token = user.sign_in_tokens.create!(auth_type: :email)
|
||||
|
||||
get auth_token_path(token: sign_in_token.token)
|
||||
|
|
@ -133,20 +114,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "email token sends user without heartbeats to wakatime setup when no continue param" do
|
||||
test "email token rejects external continue URL" do
|
||||
user = User.create!
|
||||
sign_in_token = user.sign_in_tokens.create!(auth_type: :email)
|
||||
|
||||
get auth_token_path(token: sign_in_token.token)
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to my_wakatime_setup_path
|
||||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "email token rejects external continue URL for user with heartbeats" do
|
||||
user = User.create!
|
||||
user.heartbeats.create!(time: Time.current.to_f, source_type: :test_entry)
|
||||
sign_in_token = user.sign_in_tokens.create!(
|
||||
auth_type: :email,
|
||||
continue_param: "https://evil.example.com/phish"
|
||||
|
|
@ -159,9 +128,8 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "email token rejects protocol-relative continue URL for user with heartbeats" do
|
||||
test "email token rejects protocol-relative continue URL" do
|
||||
user = User.create!
|
||||
user.heartbeats.create!(time: Time.current.to_f, source_type: :test_entry)
|
||||
sign_in_token = user.sign_in_tokens.create!(
|
||||
auth_type: :email,
|
||||
continue_param: "//evil.example.com/phish"
|
||||
|
|
@ -202,80 +170,4 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_redirected_to root_path
|
||||
assert_nil session[:user_id]
|
||||
end
|
||||
|
||||
# -- HCA callback: new user without heartbeats --
|
||||
|
||||
test "hca_create redirects user without heartbeats to wakatime setup" do
|
||||
user = User.create!
|
||||
User.define_singleton_method(:from_hca_token) { |_code, _uri| user }
|
||||
get "/auth/hca/callback", params: { code: "fake_code" }
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to my_wakatime_setup_path
|
||||
assert_equal user.id, session[:user_id]
|
||||
ensure
|
||||
User.singleton_class.remove_method(:from_hca_token)
|
||||
end
|
||||
|
||||
test "hca_create stores continue in session for user without heartbeats" do
|
||||
user = User.create!
|
||||
oauth_path = "/oauth/authorize?client_id=test&response_type=code"
|
||||
|
||||
User.define_singleton_method(:from_hca_token) { |_code, _uri| user }
|
||||
get "/auth/hca/callback", params: { code: "fake_code", continue: oauth_path }
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to my_wakatime_setup_path
|
||||
assert_equal oauth_path, session.dig(:return_data, "url")
|
||||
ensure
|
||||
User.singleton_class.remove_method(:from_hca_token)
|
||||
end
|
||||
|
||||
test "hca_create does not overwrite return_data with nil for unsafe continue URL" do
|
||||
user = User.create!
|
||||
|
||||
# Simulate hca_new having set a valid return URL
|
||||
get hca_auth_path(continue: "/oauth/authorize?client_id=test")
|
||||
|
||||
User.define_singleton_method(:from_hca_token) { |_code, _uri| user }
|
||||
get "/auth/hca/callback", params: { code: "fake_code", continue: "https://evil.example.com" }
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to my_wakatime_setup_path
|
||||
assert_equal "/oauth/authorize?client_id=test", session.dig(:return_data, "url")
|
||||
ensure
|
||||
User.singleton_class.remove_method(:from_hca_token)
|
||||
end
|
||||
|
||||
# -- Slack callback: new user without heartbeats --
|
||||
|
||||
test "slack_create redirects user without heartbeats to wakatime setup" do
|
||||
user = User.create!
|
||||
state = { "continue" => "/oauth/authorize?client_id=test" }.to_json
|
||||
|
||||
User.define_singleton_method(:from_slack_token) { |_code, _uri| user }
|
||||
get "/auth/slack/callback", params: { code: "fake_code", state: state }
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to my_wakatime_setup_path
|
||||
assert_equal user.id, session[:user_id]
|
||||
assert_equal "/oauth/authorize?client_id=test", session.dig(:return_data, "url")
|
||||
ensure
|
||||
User.singleton_class.remove_method(:from_slack_token)
|
||||
end
|
||||
|
||||
test "slack_create redirects user with heartbeats to continue URL" do
|
||||
user = User.create!
|
||||
user.heartbeats.create!(time: Time.current.to_f, source_type: :test_entry)
|
||||
oauth_path = "/oauth/authorize?client_id=test&response_type=code"
|
||||
state = { "continue" => oauth_path }.to_json
|
||||
|
||||
User.define_singleton_method(:from_slack_token) { |_code, _uri| user }
|
||||
get "/auth/slack/callback", params: { code: "fake_code", state: state }
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to oauth_path
|
||||
ensure
|
||||
User.singleton_class.remove_method(:from_slack_token)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue