Spring cleaning (#1074)

* fix: use owner/repo format for project badges

Updates badge URLs to use GitHub-style owner/repo format (e.g., "hackclub/hackatime")
instead of project names. This ensures compatibility with external badge services that
expect repository paths.

Changes:
- Add Repository#full_path method to get owner/repo format
- Update settings controller to pass both display names and repo paths
- Update Badges component to display project names but use repo paths in URLs

* fix: improve user lookup in API v1 stats endpoint

Use the robust lookup_user method for username parameter in the /api/v1/stats endpoint
to ensure consistent user lookup across all API endpoints. This properly handles Slack
UIDs (HCA IDs), numeric user IDs, and usernames in the correct priority order.

* fix: reduce clutter on new user homepage

Simplify the new user experience by:
- Removing redundant "Hello friend" text from setup notice (header already provides context)
- Hiding GitHub link banner when setup notice is shown to focus user on primary action

This reduces visual clutter and helps new users focus on completing setup first.

* fix: enable full app layout for new OAuth application page

Remove layout=false directive that was preventing the app header and navigation
from appearing on the new OAuth application creation page.

* fix: add antigravity editor to docs

Add documentation for Antigravity, a VSCode fork from Google with built-in AI
features. Includes setup instructions for tracking time with Hackatime using the
WakaTime extension.

* fix: improve stat card subtitle positioning

Remove absolute positioning from subtitle text to allow it to flow naturally after
the main value. This prevents the subtitle from being pushed to the bottom when
other cards have longer content.

* fix: align settings action buttons to card end on larger screens

Remove width constraint from footer to allow action buttons to align to the right
edge of the full card width instead of being constrained to a narrower container.

* fix: improve heartbeat importer visibility on light themes

Update import provider cards and radio buttons to have better contrast on light themes:
- Use bg-surface-100 instead of bg-darker for better card visibility
- Increase radio button border thickness and use darker border color
- Add hover and focus states for better interactivity

* Split up settings controller + perf + goal display

* Make stat card subtitles larger

* Fix AG + VS Code

* Remove Shiba refs

* Bundle update
This commit is contained in:
Mahad Kalam 2026-03-15 15:26:32 +00:00 committed by GitHub
parent 49ea53128d
commit 8d00418059
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 574 additions and 421 deletions

8
.claude/settings.json Normal file
View file

@ -0,0 +1,8 @@
{
"includeCoAuthoredBy": false,
"gitAttribution": false,
"attribution": {
"commits": false,
"pullRequests": false
}
}

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
Read @AGENTS.md

View file

@ -82,7 +82,7 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1225.0)
aws-partitions (1.1226.0)
aws-sdk-core (3.243.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@ -337,7 +337,7 @@ GEM
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
net-ssh (7.3.0)
net-ssh (7.3.1)
nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
@ -357,7 +357,7 @@ GEM
faraday (>= 1.0, < 3.0)
faraday-net_http_persistent
net-http-persistent
oj (3.16.15)
oj (3.16.16)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
ostruct (0.6.3)
@ -548,8 +548,8 @@ GEM
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
logger
skylight (7.0.0)
activesupport (>= 7.1.0)
skylight (7.1.0)
activesupport (>= 7.2.0)
slack-ruby-client (3.1.0)
faraday (>= 2.0.1)
faraday-mashify
@ -588,12 +588,12 @@ GEM
tailwindcss-rails (4.4.0)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.2.0)
tailwindcss-ruby (4.2.0-aarch64-linux-gnu)
tailwindcss-ruby (4.2.0-aarch64-linux-musl)
tailwindcss-ruby (4.2.0-arm64-darwin)
tailwindcss-ruby (4.2.0-x86_64-linux-gnu)
tailwindcss-ruby (4.2.0-x86_64-linux-musl)
tailwindcss-ruby (4.2.1)
tailwindcss-ruby (4.2.1-aarch64-linux-gnu)
tailwindcss-ruby (4.2.1-aarch64-linux-musl)
tailwindcss-ruby (4.2.1-arm64-darwin)
tailwindcss-ruby (4.2.1-x86_64-linux-gnu)
tailwindcss-ruby (4.2.1-x86_64-linux-musl)
thor (1.5.0)
thruster (0.1.19)
thruster (0.1.19-aarch64-linux)

View file

@ -46,14 +46,39 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
def status_bar_today
Time.use_zone(@user.timezone) do
hbt = @user.heartbeats.today
total_seconds = hbt.duration_seconds
# Check if user has a daily goal
daily_goal = @user.goals.find_by(period: "day")
result = {
data: {
grand_total: {
text: @user.format_extension_text(hbt.duration_seconds),
total_seconds: hbt.duration_seconds
text: @user.format_extension_text(total_seconds),
total_seconds: total_seconds
}
}
}
# Include goal information if daily goal exists
if daily_goal
goal_progress = ProgrammingGoalsProgressService.new(user: @user, goals: [ daily_goal ]).call.first
if goal_progress
# Append goal progress to the user's preferred text format
user_text = result[:data][:grand_total][:text]
goal_text = ApplicationController.helpers.short_time_simple(daily_goal.target_seconds)
result[:data][:grand_total][:text] = "#{user_text} / #{goal_text} today"
result[:data][:goal] = {
target_seconds: daily_goal.target_seconds,
tracked_seconds: goal_progress[:tracked_seconds],
completion_percent: goal_progress[:completion_percent],
complete: goal_progress[:complete]
}
end
end
render json: result
end
end

View file

@ -12,7 +12,7 @@ class Api::V1::StatsController < ApplicationController
query = Heartbeat.where(time: start_date..end_date)
if params[:username].present?
user = User.find_by(username: params[:username]) || User.find_by(slack_uid: params[:username])
user = lookup_user(params[:username])
return render json: { error: "User not found" }, status: :not_found unless user
query = query.where(user_id: user.id)

View file

@ -11,8 +11,8 @@ class DocsController < InertiaController
].freeze
ALL_EDITORS = [
[ "Android Studio", "android-studio" ], [ "AppCode", "appcode" ], [ "Aptana", "aptana" ],
[ "Arduino IDE", "arduino-ide" ], [ "Azure Data Studio", "azure-data-studio" ],
[ "Android Studio", "android-studio" ], [ "Antigravity", "antigravity" ], [ "AppCode", "appcode" ],
[ "Aptana", "aptana" ], [ "Arduino IDE", "arduino-ide" ], [ "Azure Data Studio", "azure-data-studio" ],
[ "Brackets", "brackets" ],
[ "C++ Builder", "c++-builder" ],
[ "CLion", "clion" ], [ "Cloud9", "cloud9" ], [ "Coda", "coda" ],

View file

@ -37,6 +37,24 @@ class Settings::AccessController < Settings::BaseController
)
end
def section_props
api_key_token = @user.api_keys.last&.token
{
settings_update_path: my_settings_access_path,
user: user_props,
options: options_props,
paths: paths_props,
config_file: {
content: generated_wakatime_config(api_key_token),
has_api_key: api_key_token.present?,
empty_message: "No API key is available yet. Rotate your API key to generate one.",
api_key: api_key_token,
api_url: "https://#{request.host_with_port}/api/hackatime/v1"
}
}
end
def access_params
params.require(:user).permit(:hackatime_extension_text_type)
end

View file

@ -5,4 +5,13 @@ class Settings::BadgesController < Settings::BaseController
settings_update_path: my_settings_profile_path
)
end
private
def section_props
{
options: options_props,
badges: badges_props
}
end
end

View file

@ -6,15 +6,13 @@ class Settings::BaseController < InertiaController
before_action :set_user
before_action :require_current_user
before_action :prepare_settings_page
private
def render_settings_page(active_section:, settings_update_path:, status: :ok)
render inertia: settings_component_for(active_section), props: settings_page_props(
active_section: active_section,
settings_update_path: settings_update_path
), status: status
def render_settings_page(active_section:, settings_update_path:, status: :ok, extra_props: {})
render inertia: settings_component_for(active_section), props: common_props(
active_section: active_section
).merge(section_props).merge(extra_props), status: status
end
def settings_component_for(active_section)
@ -29,45 +27,9 @@ class Settings::BaseController < InertiaController
}.fetch(active_section.to_s, "Users/Settings/Profile")
end
def prepare_settings_page
@is_own_settings = is_own_settings?
@can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write")
@imports_enabled = Flipper.enabled?(:imports, @user)
@enabled_sailors_logs = SailorsLogNotificationPreference.where(
slack_uid: @user.slack_uid,
enabled: true,
).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS)
@projects = @user.project_repo_mappings.distinct.pluck(:project_name)
heartbeat_language_and_projects = @user.heartbeats.distinct.pluck(:language, :project)
goal_languages = []
goal_projects = @projects.dup
heartbeat_language_and_projects.each do |language, project|
categorized_language = language&.categorize_language
goal_languages << categorized_language if categorized_language.present?
goal_projects << project if project.present?
end
@goal_selectable_languages = goal_languages.uniq.sort
@goal_selectable_projects = goal_projects.uniq.sort
@work_time_stats_base_url = @user.slack_uid.present? ? "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/" : nil
@work_time_stats_url = if @work_time_stats_base_url.present?
"#{@work_time_stats_base_url}#{@projects.first || 'example'}"
end
@general_badge_url = GithubReadmeStats.new(@user.id, "darcula").generate_badge_url
@latest_api_key_token = @user.api_keys.last&.token
@latest_heartbeat_import = @user.heartbeat_import_runs.latest_first.first
end
def settings_page_props(active_section:, settings_update_path:)
if active_section.to_s == "data" && @latest_heartbeat_import.present?
@latest_heartbeat_import = HeartbeatImportRunner.refresh_remote_run!(@latest_heartbeat_import)
end
heartbeats_last_7_days = @user.heartbeats.where("time >= ?", 7.days.ago.to_f).count
channel_ids = @enabled_sailors_logs.pluck(:slack_channel_id)
# Lightweight props shared by every settings page
def common_props(active_section:)
is_own = is_own_settings?
{
active_section: active_section,
@ -80,137 +42,10 @@ class Settings::BaseController < InertiaController
badges: my_settings_badges_path,
data: my_settings_data_path
},
page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"),
heading: (@is_own_settings ? "Settings" : "Settings for #{@user.display_name}"),
page_title: (is_own ? "My Settings" : "Settings | #{@user.display_name}"),
heading: (is_own ? "Settings" : "Settings for #{@user.display_name}"),
subheading: "Manage your profile, integrations, notifications, access, goals, and data tools.",
settings_update_path: settings_update_path,
create_goal_path: my_settings_goals_create_path,
username_max_length: User::USERNAME_MAX_LENGTH,
user: {
id: @user.id,
display_name: @user.display_name,
timezone: @user.timezone,
country_code: @user.country_code,
username: @user.username,
theme: @user.theme,
uses_slack_status: @user.uses_slack_status,
weekly_summary_email_enabled: @user.subscribed?("weekly_summary"),
hackatime_extension_text_type: @user.hackatime_extension_text_type,
allow_public_stats_lookup: @user.allow_public_stats_lookup,
trust_level: @user.trust_level,
can_request_deletion: @user.can_request_deletion?,
github_uid: @user.github_uid,
github_username: @user.github_username,
slack_uid: @user.slack_uid,
programming_goals: @user.goals.order(:created_at).map { |goal|
goal.as_programming_goal_payload.merge(
update_path: my_settings_goal_update_path(goal),
destroy_path: my_settings_goal_destroy_path(goal)
)
}
},
paths: {
settings_path: settings_update_path,
wakatime_setup_path: my_wakatime_setup_path,
slack_auth_path: slack_auth_path,
github_auth_path: github_auth_path,
github_unlink_path: github_unlink_path,
add_email_path: add_email_auth_path,
unlink_email_path: unlink_email_auth_path,
rotate_api_key_path: my_settings_rotate_api_key_path,
export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"),
export_range_heartbeats_path: export_my_heartbeats_path,
create_heartbeat_import_path: my_heartbeat_imports_path,
create_deletion_path: create_deletion_path
},
options: {
countries: ISO3166::Country.all.map { |country|
{
label: country.common_name,
value: country.alpha2
}
}.sort_by { |country| country[:label] },
timezones: TZInfo::Timezone.all_identifiers.sort.map { |timezone|
{ label: timezone, value: timezone }
},
extension_text_types: User.hackatime_extension_text_types.keys.map { |key|
{
label: key.humanize,
value: key
}
},
themes: User.theme_options,
badge_themes: GithubReadmeStats.themes,
goals: {
periods: Goal::PERIODS.map { |period|
{
label: period.humanize,
value: period
}
},
preset_target_seconds: Goal::PRESET_TARGET_SECONDS,
selectable_languages: @goal_selectable_languages
.map { |language| { label: language, value: language } },
selectable_projects: @goal_selectable_projects
.map { |project| { label: project, value: project } }
}
},
slack: {
can_enable_status: @can_enable_slack_status,
notification_channels: channel_ids.map { |channel_id|
{
id: channel_id,
label: "##{channel_id}",
url: "https://hackclub.slack.com/archives/#{channel_id}"
}
}
},
github: {
connected: @user.github_uid.present?,
username: @user.github_username,
profile_url: (@user.github_username.present? ? "https://github.com/#{@user.github_username}" : nil)
},
emails: @user.email_addresses.map { |email|
{
email: email.email,
source: email.source&.humanize || "Unknown",
can_unlink: @user.can_delete_email_address?(email)
}
},
badges: {
general_badge_url: @general_badge_url,
project_badge_url: @work_time_stats_url,
project_badge_base_url: @work_time_stats_base_url,
projects: @projects,
profile_url: (@user.username.present? ? "https://hackati.me/#{@user.username}" : nil),
markscribe_template: '{{ wakatimeDoubleCategoryBar "Languages:" wakatimeData.Languages "Projects:" wakatimeData.Projects 5 }}',
markscribe_reference_url: "https://github.com/taciturnaxolotl/markscribe#your-wakatime-languages-formated-as-a-bar",
markscribe_preview_image_url: "https://cdn.fluff.pw/slackcdn/524e293aa09bc5f9115c0c29c18fb4bc.png",
heatmap_badge_url: "https://heatmap.shymike.dev/?id=#{@user.id}&timezone=#{@user.timezone}",
heatmap_config_url: "https://hackatime-heatmap.shymike.dev/?id=#{@user.id}&timezone=#{@user.timezone}",
hackabox_repo_url: "https://github.com/quackclub/hacka-box",
hackabox_preview_image_url: "https://user-cdn.hackclub-assets.com/019cef81-366a-7543-ad7c-21b738310f23/image.png"
},
config_file: {
content: generated_wakatime_config(@latest_api_key_token),
has_api_key: @latest_api_key_token.present?,
empty_message: "No API key is available yet. Rotate your API key to generate one.",
api_key: @latest_api_key_token,
api_url: "https://#{request.host_with_port}/api/hackatime/v1"
},
data_export: {
total_heartbeats: number_with_delimiter(@user.heartbeats.count),
total_coding_time: @user.heartbeats.duration_simple,
heartbeats_last_7_days: number_with_delimiter(heartbeats_last_7_days),
is_restricted: (@user.trust_level == "red")
},
imports_enabled: @imports_enabled,
remote_import_cooldown_until: HeartbeatImportRunner.remote_import_cooldown_until(user: @user)&.iso8601,
latest_heartbeat_import: HeartbeatImportRunner.serialize(@latest_heartbeat_import),
ui: {
show_dev_import: Rails.env.development?,
show_imports: @imports_enabled
},
errors: {
full_messages: @user.errors.full_messages,
username: @user.errors[:username]
@ -218,6 +53,120 @@ class Settings::BaseController < InertiaController
}
end
# Subclasses override this to provide section-specific props
def section_props
{}
end
# Shared helpers used by multiple section controllers
def user_props
{
id: @user.id,
display_name: @user.display_name,
timezone: @user.timezone,
country_code: @user.country_code,
username: @user.username,
theme: @user.theme,
uses_slack_status: @user.uses_slack_status,
weekly_summary_email_enabled: @user.subscribed?("weekly_summary"),
hackatime_extension_text_type: @user.hackatime_extension_text_type,
allow_public_stats_lookup: @user.allow_public_stats_lookup,
trust_level: @user.trust_level,
can_request_deletion: @user.can_request_deletion?,
github_uid: @user.github_uid,
github_username: @user.github_username,
slack_uid: @user.slack_uid,
programming_goals: @user.goals.order(:created_at).map { |goal|
goal.as_programming_goal_payload.merge(
update_path: my_settings_goal_update_path(goal),
destroy_path: my_settings_goal_destroy_path(goal)
)
}
}
end
def paths_props
{
settings_path: my_settings_profile_path,
wakatime_setup_path: my_wakatime_setup_path,
slack_auth_path: slack_auth_path,
github_auth_path: github_auth_path,
github_unlink_path: github_unlink_path,
add_email_path: add_email_auth_path,
unlink_email_path: unlink_email_auth_path,
rotate_api_key_path: my_settings_rotate_api_key_path,
export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"),
export_range_heartbeats_path: export_my_heartbeats_path,
create_heartbeat_import_path: my_heartbeat_imports_path,
create_deletion_path: create_deletion_path
}
end
def project_list
@project_list ||= @user.project_repo_mappings.includes(:repository).distinct.map do |mapping|
{ display_name: mapping.project_name, repo_path: mapping.repository&.full_path || mapping.project_name }
end
end
def options_props
heartbeat_language_and_projects = @user.heartbeats.distinct.pluck(:language, :project)
goal_languages = []
goal_projects = project_list.map { |p| p[:display_name] }
heartbeat_language_and_projects.each do |language, project|
categorized_language = language&.categorize_language
goal_languages << categorized_language if categorized_language.present?
goal_projects << project if project.present?
end
{
countries: ISO3166::Country.all.map { |country|
{ label: country.common_name, value: country.alpha2 }
}.sort_by { |country| country[:label] },
timezones: TZInfo::Timezone.all_identifiers.sort.map { |timezone|
{ label: timezone, value: timezone }
},
extension_text_types: User.hackatime_extension_text_types.keys.map { |key|
{ label: key.humanize, value: key }
},
themes: User.theme_options,
badge_themes: GithubReadmeStats.themes,
goals: {
periods: Goal::PERIODS.map { |period|
{ label: period.humanize, value: period }
},
preset_target_seconds: Goal::PRESET_TARGET_SECONDS,
selectable_languages: goal_languages.uniq.sort
.map { |language| { label: language, value: language } },
selectable_projects: goal_projects.uniq.sort
.map { |project| { label: project, value: project } }
}
}
end
def badges_props
work_time_stats_base_url = @user.slack_uid.present? ? "https://hackatime-badge.hackclub.com/#{@user.slack_uid}/" : nil
work_time_stats_url = if work_time_stats_base_url.present? && project_list.first.present?
"#{work_time_stats_base_url}#{project_list.first[:repo_path]}"
end
{
general_badge_url: GithubReadmeStats.new(@user.id, "darcula").generate_badge_url,
project_badge_url: work_time_stats_url,
project_badge_base_url: work_time_stats_base_url,
projects: project_list,
profile_url: (@user.username.present? ? "https://hackati.me/#{@user.username}" : nil),
markscribe_template: '{{ wakatimeDoubleCategoryBar "Languages:" wakatimeData.Languages "Projects:" wakatimeData.Projects 5 }}',
markscribe_reference_url: "https://github.com/taciturnaxolotl/markscribe#your-wakatime-languages-formated-as-a-bar",
markscribe_preview_image_url: "https://cdn.fluff.pw/slackcdn/524e293aa09bc5f9115c0c29c18fb4bc.png",
heatmap_badge_url: "https://heatmap.shymike.dev/?id=#{@user.id}&timezone=#{@user.timezone}",
heatmap_config_url: "https://hackatime-heatmap.shymike.dev/?id=#{@user.id}&timezone=#{@user.timezone}",
hackabox_repo_url: "https://github.com/quackclub/hacka-box",
hackabox_preview_image_url: "https://user-cdn.hackclub-assets.com/019cef81-366a-7543-ad7c-21b738310f23/image.png"
}
end
def generated_wakatime_config(api_key)
return nil if api_key.blank?

View file

@ -12,4 +12,32 @@ class Settings::DataController < Settings::BaseController
status: status
)
end
def section_props
imports_enabled = Flipper.enabled?(:imports, @user)
latest_import = @user.heartbeat_import_runs.latest_first.first
if latest_import.present?
latest_import = HeartbeatImportRunner.refresh_remote_run!(latest_import)
end
{
user: user_props,
paths: paths_props,
data_export: InertiaRails.defer {
{
total_heartbeats: number_with_delimiter(@user.heartbeats.count),
total_coding_time: @user.heartbeats.duration_simple,
heartbeats_last_7_days: number_with_delimiter(@user.heartbeats.where("time >= ?", 7.days.ago.to_f).count),
is_restricted: (@user.trust_level == "red")
}
},
imports_enabled: imports_enabled,
remote_import_cooldown_until: HeartbeatImportRunner.remote_import_cooldown_until(user: @user)&.iso8601,
latest_heartbeat_import: HeartbeatImportRunner.serialize(latest_import),
ui: {
show_dev_import: Rails.env.development?,
show_imports: imports_enabled
}
}
end
end

View file

@ -40,10 +40,21 @@ class Settings::GoalsController < Settings::BaseController
extra_props = {}
extra_props[:goal_form] = goal_form if goal_form
render inertia: settings_component_for("goals"), props: settings_page_props(
render_settings_page(
active_section: "goals",
settings_update_path: my_settings_goals_path
).merge(extra_props), status: status
settings_update_path: my_settings_goals_path,
status: status,
extra_props: extra_props
)
end
def section_props
{
settings_update_path: my_settings_goals_path,
create_goal_path: my_settings_goals_create_path,
user: user_props,
options: options_props
}
end
def goal_params

View file

@ -24,6 +24,43 @@ class Settings::IntegrationsController < Settings::BaseController
)
end
def section_props
can_enable_slack_status = @user.slack_access_token.present? && @user.slack_scopes.include?("users.profile:write")
enabled_sailors_logs = SailorsLogNotificationPreference.where(
slack_uid: @user.slack_uid,
enabled: true,
).where.not(slack_channel_id: SailorsLog::DEFAULT_CHANNELS)
channel_ids = enabled_sailors_logs.pluck(:slack_channel_id)
{
settings_update_path: my_settings_integrations_path,
user: user_props,
slack: {
can_enable_status: can_enable_slack_status,
notification_channels: channel_ids.map { |channel_id|
{
id: channel_id,
label: "##{channel_id}",
url: "https://hackclub.slack.com/archives/#{channel_id}"
}
}
},
github: {
connected: @user.github_uid.present?,
username: @user.github_username,
profile_url: (@user.github_username.present? ? "https://github.com/#{@user.github_username}" : nil)
},
emails: @user.email_addresses.map { |email|
{
email: email.email,
source: email.source&.humanize || "Unknown",
can_unlink: @user.can_delete_email_address?(email)
}
},
paths: paths_props
}
end
def integrations_params
params.require(:user).permit(:uses_slack_status)
end

View file

@ -32,4 +32,11 @@ class Settings::NotificationsController < Settings::BaseController
status: status
)
end
def section_props
{
settings_update_path: my_settings_notifications_path,
user: user_props
}
end
end

View file

@ -23,6 +23,16 @@ class Settings::ProfileController < Settings::BaseController
)
end
def section_props
{
settings_update_path: my_settings_profile_path,
username_max_length: User::USERNAME_MAX_LENGTH,
user: user_props,
options: options_props,
badges: badges_props
}
end
def profile_params
permitted = params.require(:user).permit(
:timezone,

View file

@ -125,9 +125,7 @@
{ssp_users_recent}
{ssp_users_size}
/>
{/if}
{#if github_uid_blank}
{:else if github_uid_blank}
<GitHubLinkBanner {github_auth_path} />
{/if}

View file

@ -18,10 +18,6 @@
</script>
<div class="text-left mt-2 mb-4 flex flex-col">
<p class="mb-4 text-xl text-primary">
Hello friend! Looks like you are new around here, let's get you set up so
you can start tracking your coding time.
</p>
<div
class="mb-4 rounded-xl border border-primary/40 bg-surface-100/70 p-4 md:p-5"
>

View file

@ -14,8 +14,7 @@
<div
class={`
relative flex flex-col justify-start p-4 rounded-xl border transition-all duration-200 h-full
${subtitle ? "pb-6" : ""}
flex flex-col justify-start p-4 rounded-xl border transition-all duration-200 h-full
${
highlight
? "bg-primary/10 border-primary/30"
@ -34,8 +33,7 @@
{value || "—"}
</div>
{#if subtitle}
<span
class="absolute bottom-2 left-4 text-xs text-secondary font-normal opacity-70"
<span class="mt-1 text-sm text-secondary font-normal opacity-70"
>{subtitle}</span
>
{/if}

View file

@ -1,7 +1,3 @@
<script module lang="ts">
export const layout = false;
</script>
<script lang="ts">
import Form from "./Form.svelte";
import type { OAuthApplicationFormProps } from "./types";

View file

@ -19,7 +19,7 @@
themes.includes("darcula") ? "darcula" : themes[0] || "default";
let selectedTheme = $state("default");
let selectedProject = $state("");
let selectedProject = $state<string>("");
$effect(() => {
if (
@ -36,8 +36,11 @@
return;
}
if (!badges.projects.includes(selectedProject)) {
selectedProject = badges.projects[0];
const projectRepoPath = badges.projects.find(
(p) => p.repo_path === selectedProject,
);
if (!projectRepoPath) {
selectedProject = badges.projects[0]?.repo_path || "";
}
});
@ -49,7 +52,8 @@
const projectBadgeUrl = () => {
if (!badges.project_badge_base_url || !selectedProject) return "";
return `${badges.project_badge_base_url}${encodeURIComponent(selectedProject)}`;
// selectedProject is already in owner/repo format
return `${badges.project_badge_base_url}${selectedProject}`;
};
</script>
@ -108,8 +112,8 @@
id="badge_project"
bind:value={selectedProject}
items={badges.projects.map((project) => ({
value: project,
label: project,
value: project.repo_path,
label: project.display_name,
}))}
/>
<div class="mt-4 rounded-md border border-surface-200 bg-darker p-4">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Form, usePoll } from "@inertiajs/svelte";
import { Deferred, Form, usePoll } from "@inertiajs/svelte";
import Button from "../../../components/Button.svelte";
import SectionCard from "./components/SectionCard.svelte";
import SettingsShell from "./Shell.svelte";
@ -61,17 +61,13 @@
return Date.now() - overlayStartTime > OVERLAY_TIMEOUT_MS;
});
// Clear overlay when server confirms the import or overlay times out
const effectiveImport = $derived(() => {
// If overlay is stale, discard it
if (isOverlayStale()) {
return latest_heartbeat_import;
}
// If server has caught up to terminal state, prefer server state
if (isServerTerminal && importOverlay) {
return latest_heartbeat_import;
}
// Otherwise prefer overlay if it exists
return importOverlay ?? latest_heartbeat_import;
});
@ -224,14 +220,14 @@
<div class="space-y-3">
{#each PROVIDERS as provider}
<label
class="flex cursor-pointer items-start gap-3 rounded-md border border-surface-200 bg-darker px-3 py-3 text-sm text-surface-content"
class="flex cursor-pointer items-start gap-3 rounded-md border border-surface-200 bg-surface-100 px-3 py-3 text-sm text-surface-content hover:border-surface-300"
>
<input
type="radio"
name="heartbeat_import[provider]"
value={provider.value}
bind:group={remoteProvider}
class="mt-1 h-4 w-4 border-surface-200 bg-surface text-primary"
class="mt-1 h-4 w-4 shrink-0 cursor-pointer border-2 border-surface-300 text-primary focus:ring-2 focus:ring-primary focus:ring-offset-2"
disabled={importInProgress || processing}
/>
<span class="space-y-1">
@ -297,9 +293,9 @@
<span class="text-muted">·</span>
<span
class={importState === "failed"
? "text-red-300"
? "text-red-500 dark:text-red-300"
: importState === "completed"
? "text-green-300"
? "text-primary"
: "text-muted"}
>
{prettyStatus(importState)}
@ -364,187 +360,211 @@
description="Download your coding history as JSON for backups or analysis."
wide
>
{#if data_export.is_restricted}
<p
class="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red-200"
>
Data export is currently restricted for this account.
</p>
{:else}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Total heartbeats
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_heartbeats}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Total coding time
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_coding_time}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">Last 7 days</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.heartbeats_last_7_days}
</p>
</div>
</div>
<p class="mt-3 text-sm text-muted">
Exports are generated in the background and emailed to you.
</p>
<div class="mt-4 space-y-3">
<Form method="post" action={paths.export_all_heartbeats_path}>
{#snippet children({ processing })}
<Button type="submit" class="rounded-md" disabled={processing}>
{processing ? "Exporting..." : "Export all heartbeats"}
</Button>
{/snippet}
</Form>
<Form
method="post"
action={paths.export_range_heartbeats_path}
class="grid grid-cols-1 gap-3 rounded-md border border-surface-200 bg-darker p-4 sm:grid-cols-3"
>
{#snippet children({ processing })}
<input
type="date"
name="start_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<input
type="date"
name="end_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<Button
type="submit"
variant="surface"
class="rounded-md"
disabled={processing}
<Deferred data="data_export">
{#snippet fallback()}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
{#each Array(3) as _}
<div
class="rounded-md border border-surface-200 bg-darker px-3 py-3"
>
{processing ? "Exporting..." : "Export date range"}
</Button>
{/snippet}
</Form>
</div>
{#if ui.show_dev_import}
<Form
method="post"
action={paths.create_heartbeat_import_path}
class="mt-4 rounded-md border border-surface-200 bg-darker p-4"
resetOnSuccess={["heartbeat_file"]}
onSuccess={() => {
selectedFile = null;
}}
>
{#snippet children({ processing, errors: formErrors })}
<label
class="mb-2 block text-sm text-surface-content"
for="heartbeat_file"
>
Import heartbeats (development only)
</label>
<input
id="heartbeat_file"
name="heartbeat_file"
type="file"
accept=".json,application/json"
required
disabled={importInProgress || processing}
onchange={(event) => {
const target = event.currentTarget as HTMLInputElement;
selectedFile = target.files?.[0] ?? null;
}}
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content disabled:cursor-not-allowed disabled:opacity-60"
/>
{#if formErrors.import}
<p class="mt-2 text-sm text-red-300">{formErrors.import}</p>
{/if}
<Button
type="submit"
variant="surface"
class="mt-3 rounded-md"
disabled={!selectedFile || importInProgress || processing}
>
{#if processing}
Starting import...
{:else if importInProgress && latestImportIsDev}
Importing...
{:else}
Import file
{/if}
</Button>
{#if importState !== "idle" && latestImportIsDev}
<div class="h-3 w-24 animate-pulse rounded bg-surface-200"></div>
<div
class="mt-4 flex items-center gap-2 rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm"
class="mt-3 h-5 w-16 animate-pulse rounded bg-surface-200"
></div>
</div>
{/each}
</div>
<div class="mt-3 h-4 w-64 animate-pulse rounded bg-surface-200"></div>
{/snippet}
{#if !data_export}
<!-- waiting for deferred data -->
{:else if data_export.is_restricted}
<p
class="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-red-200"
>
Data export is currently restricted for this account.
</p>
{:else}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Total heartbeats
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_heartbeats}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Total coding time
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.total_coding_time}
</p>
</div>
<div class="rounded-md border border-surface-200 bg-darker px-3 py-3">
<p class="text-xs uppercase tracking-wide text-muted">
Last 7 days
</p>
<p class="mt-1 text-lg font-semibold text-surface-content">
{data_export.heartbeats_last_7_days}
</p>
</div>
</div>
<p class="mt-3 text-sm text-muted">
Exports are generated in the background and emailed to you.
</p>
<div class="mt-4 space-y-3">
<Form method="post" action={paths.export_all_heartbeats_path}>
{#snippet children({ processing })}
<Button type="submit" class="rounded-md" disabled={processing}>
{processing ? "Exporting..." : "Export all heartbeats"}
</Button>
{/snippet}
</Form>
<Form
method="post"
action={paths.export_range_heartbeats_path}
class="grid grid-cols-1 gap-3 rounded-md border border-surface-200 bg-darker p-4 sm:grid-cols-3"
>
{#snippet children({ processing })}
<input
type="date"
name="start_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<input
type="date"
name="end_date"
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<Button
type="submit"
variant="surface"
class="rounded-md"
disabled={processing}
>
{#if importInProgress}
<svg
class="h-4 w-4 shrink-0 animate-spin text-primary"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
class="opacity-25"
/>
<path
d="M4 12a8 8 0 018-8"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
{processing ? "Exporting..." : "Export date range"}
</Button>
{/snippet}
</Form>
</div>
{#if ui.show_dev_import}
<Form
method="post"
action={paths.create_heartbeat_import_path}
class="mt-4 rounded-md border border-surface-200 bg-darker p-4"
resetOnSuccess={["heartbeat_file"]}
onSuccess={() => {
selectedFile = null;
}}
>
{#snippet children({ processing, errors: formErrors })}
<label
class="mb-2 block text-sm text-surface-content"
for="heartbeat_file"
>
Import heartbeats (development only)
</label>
<input
id="heartbeat_file"
name="heartbeat_file"
type="file"
accept=".json,application/json"
required
disabled={importInProgress || processing}
onchange={(event) => {
const target = event.currentTarget as HTMLInputElement;
selectedFile = target.files?.[0] ?? null;
}}
class="w-full rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content disabled:cursor-not-allowed disabled:opacity-60"
/>
{#if formErrors.import}
<p class="mt-2 text-sm text-red-300">{formErrors.import}</p>
{/if}
<Button
type="submit"
variant="surface"
class="mt-3 rounded-md"
disabled={!selectedFile || importInProgress || processing}
>
{#if processing}
Starting import...
{:else if importInProgress && latestImportIsDev}
Importing...
{:else}
Import file
{/if}
<span class="text-surface-content">
{activeImport?.source_filename ||
providerLabel(importSourceKind)}
</span>
<span class="text-muted">·</span>
<span
class={importState === "failed"
? "text-red-300"
: importState === "completed"
? "text-green-300"
: "text-muted"}
</Button>
{#if importState !== "idle" && latestImportIsDev}
<div
class="mt-4 flex items-center gap-2 rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm"
>
{prettyStatus(importState)}
</span>
{#if activeImport?.error_message}
<span class="text-red-300">{activeImport.error_message}</span>
{/if}
{#if importState === "completed"}
<span class="text-muted">
{formatCount(activeImport?.imported_count)} imported, {formatCount(
activeImport?.skipped_count,
)} skipped
{#if importInProgress}
<svg
class="h-4 w-4 shrink-0 animate-spin text-primary"
viewBox="0 0 24 24"
fill="none"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
class="opacity-25"
/>
<path
d="M4 12a8 8 0 018-8"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
{/if}
<span class="text-surface-content">
{activeImport?.source_filename ||
providerLabel(importSourceKind)}
</span>
{/if}
</div>
{/if}
{/snippet}
</Form>
<span class="text-muted">·</span>
<span
class={importState === "failed"
? "text-red-300"
: importState === "completed"
? "text-green-300"
: "text-muted"}
>
{prettyStatus(importState)}
</span>
{#if activeImport?.error_message}
<span class="text-red-300"
>{activeImport.error_message}</span
>
{/if}
{#if importState === "completed"}
<span class="text-muted">
{formatCount(activeImport?.imported_count)} imported, {formatCount(
activeImport?.skipped_count,
)} skipped
</span>
{/if}
</div>
{/if}
{/snippet}
</Form>
{/if}
{/if}
{/if}
</Deferred>
</SectionCard>
{#if user.can_request_deletion}

View file

@ -60,11 +60,9 @@
{#if footer}
<div
data-settings-footer
class="border-t border-surface-200 bg-surface-100/60 px-5 py-3.5 sm:px-6 sm:py-4"
class={`border-t border-surface-200 bg-surface-100/60 px-5 py-3.5 sm:px-6 sm:py-4 ${footerClass}`}
>
<div class={`${contentWidth} ${footerClass}`}>
{@render footer()}
</div>
{@render footer()}
</div>
{/if}
</section>

View file

@ -138,7 +138,7 @@ export type BadgesProps = {
general_badge_url: string;
project_badge_url?: string | null;
project_badge_base_url?: string | null;
projects: string[];
projects: Array<{ display_name: string; repo_path: string }>;
profile_url?: string | null;
markscribe_template: string;
markscribe_reference_url: string;
@ -250,7 +250,7 @@ export type BadgesPageProps = SettingsCommonProps & {
export type DataPageProps = SettingsCommonProps & {
user: UserProps;
paths: PathsProps;
data_export: DataExportProps;
data_export?: DataExportProps;
imports_enabled: boolean;
remote_import_cooldown_until?: string | null;
latest_heartbeat_import?: HeartbeatImportStatusProps | null;

View file

@ -13,6 +13,11 @@ class Repository < ApplicationRecord
last_synced_at.nil? || last_synced_at < 1.day.ago
end
# Get owner/repo format (e.g., "hackclub/hackatime")
def full_path
"#{owner}/#{name}"
end
# Get formatted languages list
def formatted_languages
return nil if languages.blank?

View file

@ -0,0 +1,12 @@
# Antigravity
Antigravity is a VSCode fork from Google with built-in AI features for code assistance and generation.
## Setup
Antigravity uses the WakaTime extension for time tracking. Follow these steps to track your coding time with Hackatime:
1. Configure your editor by using our [simple installer](https://hackatime.hackclub.com/my/wakatime_setup)
2. Restart Antigravity and start coding!
The WakaTime extension will automatically track your time spent coding in Antigravity.

View file

@ -8,7 +8,7 @@ Follow these steps to start tracking your game development in Godot with Hackati
**[hackatime.hackclub.com](https://hackatime.hackclub.com)**
Sign in with your Hack Club Slack account. If you haven't created one yet, head to **[shiba.hackclub.com](https://shiba.hackclub.com)** and enter your email address to get a link to the Slack :)
Sign in with your Hackatime account (or create one if you don't have one yet)!
## 2: Set up Hackatime
@ -22,9 +22,7 @@ This will set some environment variables and tell Hackatime where to "phone home
**[godotengine.org/download](https://godotengine.org/download)**
Download the Godot binary! Make sure you get the **regular version** and not the .NET version - web exports (required for Shiba) aren't supported with .NET.
If you're on Linux and know what you're doing, make sure to check your distro's package manager for any special builds.
Download the Godot binary! If you're on Linux and know what you're doing, make sure to check your distro's package manager for any special builds.
## 4: Create a New Godot Project
@ -65,20 +63,12 @@ If you'd like to be able to run your game in browser, choose the **Mobile** rend
**Important:** You need to install Godot Super WakaTime for every project (it's a Godot limitation)
## All Done!
## All Done!
After you're finished, make sure to check **[hackatime.hackclub.com](https://hackatime.hackclub.com)** after a little while and ensure you're logging progress!
You can also try some **[wakatime.com/plugins](https://wakatime.com/plugins)** if you'd like to log time spent editing your project in other programs.
## Features
This Hack Club-approved plugin provides:
- **Accurate tracking** - Differentiates between script editing and scene building
- **Detailed metrics** - Counts keystrokes as coding, mouse clicks as scene work
- **Smart detection** - Tracks scene structure changes and file modifications
- **Seamless integration** - Works with your existing Hackatime setup
## Troubleshooting
- **Not seeing your time?** Make sure you completed the [setup page](https://hackatime.hackclub.com/my/wakatime_setup) first

View file

@ -2,6 +2,13 @@
![VS Code](/images/editor-icons/vs-code-128.png)
## Automatic Setup
1. Configure your editor by using our [simple installer](https://hackatime.hackclub.com/my/wakatime_setup)
2. Restart VS Code and start coding!
---
Let's set up VS Code to count how much time you spend coding!
## Step 1: Make a Hackatime Account
@ -10,7 +17,7 @@ Go to [Hackatime](https://hackatime.hackclub.com) and make an account. Then log
## Step 2: Get Your Settings Ready
Click this link to the [setup page](https://hackatime.hackclub.com/my/wakatime_setup). It will set up your account so it works with VS Code.
Click this link to the [setup page](https://hackatime.hackclub.com/my/wakatime_setup). It will set up your device so it works with Hackatime.
## Step 3: Add the Plugin to VS Code
@ -18,7 +25,7 @@ Click this link to the [setup page](https://hackatime.hackclub.com/my/wakatime_s
2. Click the squares icon on the left (Extensions)
3. Type "WakaTime" in the search box
4. Click the **Install** button next to WakaTime
5. VS Code might ask you to restart - click **Reload** if it does
5. VS Code might ask you to reload your extensions to get it working - click **Reload Extensions** if it does
That's it! The plugin will use your settings from Step 2.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -90,7 +90,17 @@ RSpec.describe 'Api::Hackatime::V1::Compatibility', type: :request do
type: :object,
properties: {
total_seconds: { type: :number, example: 7200.0 },
text: { type: :string, example: '2 hrs' }
text: { type: :string, example: '2h 30m / 4h today' }
}
},
goal: {
type: :object,
nullable: true,
properties: {
target_seconds: { type: :number, example: 14400 },
tracked_seconds: { type: :number, example: 9000 },
completion_percent: { type: :number, example: 62 },
complete: { type: :boolean, example: false }
}
}
}

View file

@ -1695,7 +1695,23 @@ paths:
example: 7200.0
text:
type: string
example: 2 hrs
example: 2h 30m / 4h today
goal:
type: object
nullable: true
properties:
target_seconds:
type: number
example: 14400
tracked_seconds:
type: number
example: 9000
completion_percent:
type: number
example: 62
complete:
type: boolean
example: false
'401':
description: unauthorized
"/api/hackatime/v1/users/current/stats/last_7_days":