Merge branch 'main' into oauth-creds-rotate

This commit is contained in:
Mahad Kalam 2026-02-15 13:27:39 +00:00 committed by GitHub
commit 32a10f34fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 836 additions and 441 deletions

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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!"

View file

@ -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"),

View file

@ -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}

View file

@ -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 -->

View file

@ -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>

View file

@ -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">

View file

@ -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"

View file

@ -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' },

View file

@ -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"

View file

@ -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

View file

@ -3,7 +3,6 @@ class Heartbeat < ApplicationRecord
include Heartbeatable
include TimeRangeFilterable
include PublicActivity::Common
time_range_filterable_field :time

View file

@ -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

View 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

View file

@ -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' %>

View file

@ -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>

View file

@ -1,2 +0,0 @@
<%= render 'shared/user_mention', user: activity.owner %>
just hit their first 7 day coding streak!

View file

@ -1,2 +0,0 @@
<%= render 'shared/user_mention', user: activity.owner %>
was just sent a letter for '<%= activity.parameters[:humanized_mission_type] %>'

View file

@ -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']) %>

View file

@ -1,2 +0,0 @@
<%= render 'shared/user_mention', user: activity.owner %>
just started tracking their coding time on <%= activity.parameters['project'] %>!

View file

@ -1,2 +0,0 @@
<%= render 'shared/user_mention', user: activity.owner %>
just signed in for the first time

View file

@ -1,2 +0,0 @@
<%= render 'shared/user_mention', user: activity.owner %>
just started working on <%= activity.parameters['project'] %>

View file

@ -188,12 +188,6 @@
<% end %>
</div>
<% end %>
<% if defined?(@activities) %>
<div class="pt-2">
<%= render_activities(@activities) %>
</div>
<% end %>
</nav>
</div>
</aside>

View file

@ -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>

View file

@ -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=="],

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View 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
View file

@ -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

View file

@ -5,6 +5,7 @@ services:
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
- "3036:3036"
volumes:
- .:/app
- bundle_cache:/usr/local/bundle

View file

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View 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

View file

@ -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