diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index c551704..76bda61 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -217,12 +217,27 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController end || {} end + LAST_LANGUAGE_SENTINEL = "<>" + def handle_heartbeat(heartbeat_array) results = [] + last_language = nil heartbeat_array.each do |heartbeat| heartbeat = heartbeat.to_h.with_indifferent_access source_type = :direct_entry + # Resolve <> sentinel to the most recently used language. + # Check within the current batch first, then fall back to the DB. + if heartbeat[:language] == LAST_LANGUAGE_SENTINEL + heartbeat[:language] = last_language || @user.heartbeats + .where.not(language: [ nil, "", LAST_LANGUAGE_SENTINEL ]) + .order(time: :desc) + .pick(:language) + end + + # Track the last known language for subsequent heartbeats in this batch. + last_language = heartbeat[:language] if heartbeat[:language].present? + # Fallback to :plugin if :user_agent is not set fallback_value = heartbeat.delete(:plugin) heartbeat[:user_agent] ||= fallback_value diff --git a/app/controllers/concerns/dashboard_data.rb b/app/controllers/concerns/dashboard_data.rb index 49fcca6..6fa4cb5 100644 --- a/app/controllers/concerns/dashboard_data.rb +++ b/app/controllers/concerns/dashboard_data.rb @@ -19,7 +19,11 @@ module DashboardData options = current_user.heartbeats.distinct.pluck(f).compact_blank options = options.reject { |n| archived.include?(n) } if f == :project result[f] = options.map { |k| - f == :language ? k.categorize_language : (%i[operating_system editor].include?(f) ? k.capitalize : k) + if f == :language then k.categorize_language + elsif f == :editor then h.display_editor_name(k) + elsif f == :operating_system then h.display_os_name(k) + else k + end }.uniq next unless params[f].present? diff --git a/app/controllers/my/project_repo_mappings_controller.rb b/app/controllers/my/project_repo_mappings_controller.rb index cf9740c..8bb528a 100644 --- a/app/controllers/my/project_repo_mappings_controller.rb +++ b/app/controllers/my/project_repo_mappings_controller.rb @@ -31,7 +31,7 @@ class My::ProjectRepoMappingsController < InertiaController def update if @project_repo_mapping.new_record? - @project_repo_mapping.project_name = CGI.unescape(params[:project_name]) + @project_repo_mapping.project_name = params[:project_name] end if @project_repo_mapping.update(project_repo_mapping_params) @@ -68,16 +68,14 @@ class My::ProjectRepoMappingsController < InertiaController end def set_project_repo_mapping_for_edit - decoded_project_name = CGI.unescape(params[:project_name]) @project_repo_mapping = current_user.project_repo_mappings.find_or_initialize_by( - project_name: decoded_project_name + project_name: params[:project_name] ) end def set_project_repo_mapping - decoded_project_name = CGI.unescape(params[:project_name]) @project_repo_mapping = current_user.project_repo_mappings.find_or_create_by!( - project_name: decoded_project_name + project_name: params[:project_name] ) end @@ -143,10 +141,10 @@ class My::ProjectRepoMappingsController < InertiaController repository: repository_payload(mapping&.repository, latest_user_commit_at_by_repo_id), broken_name: broken_project_name?(project_key, display_name), manage_enabled: current_user.github_uid.present? && project_key.present?, - edit_path: project_key.present? ? edit_my_project_repo_mapping_path(CGI.escape(project_key)) : nil, - update_path: project_key.present? ? my_project_repo_mapping_path(CGI.escape(project_key)) : nil, - archive_path: project_key.present? ? archive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil, - unarchive_path: project_key.present? ? unarchive_my_project_repo_mapping_path(CGI.escape(project_key)) : nil + edit_path: project_key.present? ? edit_my_project_repo_mapping_path(project_key) : nil, + update_path: project_key.present? ? my_project_repo_mapping_path(project_key) : nil, + archive_path: project_key.present? ? archive_my_project_repo_mapping_path(project_key) : nil, + unarchive_path: project_key.present? ? unarchive_my_project_repo_mapping_path(project_key) : nil } end.sort_by { |project| -project[:duration_seconds] } diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 383d34c..925a166 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -167,7 +167,8 @@ class StaticPagesController < InertiaController show_dev_tool: Rails.env.development?, dev_magic_link: (Rails.env.development? ? session.delete(:dev_magic_link) : nil), csrf_token: form_authenticity_token, - home_stats: @home_stats || {} + home_stats: @home_stats || {}, + flash: inertia_flash_messages } end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e436ae2..7a6334c 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -2,6 +2,7 @@ class UsersController < InertiaController layout "inertia", only: %i[wakatime_setup wakatime_setup_step_2 wakatime_setup_step_3 wakatime_setup_step_4] before_action :ensure_current_user_for_setup, only: %i[wakatime_setup wakatime_setup_step_2 wakatime_setup_step_3 wakatime_setup_step_4] + before_action :set_wakatime_setup_meta, only: %i[wakatime_setup wakatime_setup_step_2 wakatime_setup_step_3 wakatime_setup_step_4] before_action :require_admin, only: [ :update_trust_level ] def wakatime_setup @@ -86,7 +87,14 @@ class UsersController < InertiaController private def ensure_current_user_for_setup - redirect_to root_path, alert: "You need to log in!" if current_user.nil? + redirect_to signin_path(continue: request.fullpath), alert: "Please sign in to set up your editor." if current_user.nil? + end + + def set_wakatime_setup_meta + @page_title = "Set Up Your Editor - Hackatime" + @meta_description = "Connect your code editor to Hackatime in minutes. Install the WakaTime plugin and start tracking your coding time for free." + @og_title = "Set Up Your Editor - Hackatime" + @og_description = "Connect your code editor to Hackatime in minutes. Install the WakaTime plugin and start tracking your coding time for free." end def require_admin diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3589caa..42943db 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -167,9 +167,9 @@ module ApplicationHelper return "Unknown" if editor.blank? case editor.downcase - when "vscode" then "VS Code" + when "vscode", "vs code" then "VS Code" when "pycharm" then "PyCharm" - when "intellij" then "IntelliJ IDEA" + when "intellij", "intellijidea" then "IntelliJ IDEA" when "webstorm" then "WebStorm" when "phpstorm" then "PhpStorm" when "datagrip" then "DataGrip" @@ -178,6 +178,7 @@ module ApplicationHelper when "visual studio" then "Visual Studio" when "sublime text" then "Sublime Text" when "iterm2" then "iTerm2" + when "rubymine" then "RubyMine" else editor.capitalize end end @@ -188,6 +189,8 @@ module ApplicationHelper case os.downcase when "darwin" then "macOS" when "macos" then "macOS" + when "wsl" then "WSL" + when "mozilla" then "Firefox" else os.capitalize end end diff --git a/app/javascript/pages/Home/SignedOut.svelte b/app/javascript/pages/Home/SignedOut.svelte index c21f6cb..511ea0a 100644 --- a/app/javascript/pages/Home/SignedOut.svelte +++ b/app/javascript/pages/Home/SignedOut.svelte @@ -14,6 +14,8 @@ type HomeStats = { seconds_tracked?: number; users_tracked?: number }; + type FlashMessage = { message: string; class_name: string }; + let { hca_auth_path, slack_auth_path, @@ -23,6 +25,7 @@ dev_magic_link, csrf_token, home_stats, + flash = [], }: { hca_auth_path: string; slack_auth_path: string; @@ -32,6 +35,7 @@ dev_magic_link?: string | null; csrf_token: string; home_stats: HomeStats; + flash?: FlashMessage[]; } = $props(); let previousTheme = $state(null); @@ -59,10 +63,53 @@ : 0, ); const usersTracked = $derived(home_stats?.users_tracked ?? 0); + + let flashVisible = $state(false); + let flashHiding = $state(false); + const flashHideDelay = 6000; + const flashExitDuration = 250; + + $effect(() => { + if (!flash.length) { + flashVisible = false; + flashHiding = false; + return; + } + + flashVisible = true; + flashHiding = false; + let removeTimeoutId: ReturnType | undefined; + const hideTimeoutId = setTimeout(() => { + flashHiding = true; + removeTimeoutId = setTimeout(() => { + flashVisible = false; + flashHiding = false; + }, flashExitDuration); + }, flashHideDelay); + + return () => { + clearTimeout(hideTimeoutId); + if (removeTimeoutId) clearTimeout(removeTimeoutId); + }; + });
- + {#if flashVisible && flash.length > 0} +
+ {#each flash as item} +
+ {item.message} +
+ {/each} +
+ {/if} + +
@@ -103,7 +150,7 @@ href="/signin" class="px-4 py-2 bg-primary text-on-primary rounded-md font-semibold hover:opacity-90 transition-colors" > - Start tracking + Sign in
diff --git a/app/javascript/pages/Users/Settings/Notifications.svelte b/app/javascript/pages/Users/Settings/Notifications.svelte index 334c6cd..eed584b 100644 --- a/app/javascript/pages/Users/Settings/Notifications.svelte +++ b/app/javascript/pages/Users/Settings/Notifications.svelte @@ -71,7 +71,7 @@ {/snippet} - Weekly coding summary email (sent Fridays at 5:30 PM GMT) + Weekly coding summary email (sent Sundays at 6:30 PM GMT)

Includes your weekly coding time, top projects, and top languages. diff --git a/app/jobs/weekly_summary_user_email_job.rb b/app/jobs/weekly_summary_user_email_job.rb index c65a092..0eb4312 100644 --- a/app/jobs/weekly_summary_user_email_job.rb +++ b/app/jobs/weekly_summary_user_email_job.rb @@ -13,8 +13,8 @@ class WeeklySummaryUserEmailJob < ApplicationJob now_utc = Time.zone.parse(now_utc_iso8601) user_timezone = ActiveSupport::TimeZone[user.timezone] || ActiveSupport::TimeZone["UTC"] user_now = now_utc.in_time_zone(user_timezone) - ends_at_local = user_now.beginning_of_week(:monday) - starts_at_local = ends_at_local - 1.week + starts_at_local = user_now.beginning_of_week(:sunday) - 7.days + ends_at_local = user_now.beginning_of_week(:sunday) WeeklySummaryMailer.weekly_summary( user, diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 298fd80..05b3307 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,6 +1,6 @@ class ApplicationMailer < ActionMailer::Base include Mailkick::UrlHelper - default from: ENV.fetch("SMTP_FROM_EMAIL", "noreply@timedump.hackclub.com") + default from: "Hackatime <#{ENV.fetch("SMTP_FROM_EMAIL", "noreply@timedump.hackclub.com")}>" layout "mailer" end diff --git a/app/mailers/weekly_summary_mailer.rb b/app/mailers/weekly_summary_mailer.rb index b773a97..632652a 100644 --- a/app/mailers/weekly_summary_mailer.rb +++ b/app/mailers/weekly_summary_mailer.rb @@ -17,7 +17,8 @@ class WeeklySummaryMailer < ApplicationMailer coding_heartbeats = @user.heartbeats.where(time: @starts_at.to_f...@ends_at.to_f) @total_seconds = coding_heartbeats.duration_seconds - @daily_average_seconds = (@total_seconds / 7.0).round + num_days = [ (@ends_at - @starts_at) / 1.day, 1 ].max + @daily_average_seconds = (@total_seconds / num_days).round @total_heartbeats = coding_heartbeats.count @active_days = active_days_count(coding_heartbeats) @top_projects = breakdown(coding_heartbeats, :project) diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb index 7785ac8..17d25ae 100644 --- a/config/initializers/good_job.rb +++ b/config/initializers/good_job.rb @@ -110,9 +110,9 @@ Rails.application.configure do kwargs: { force_reload: true } }, weekly_summary_email: { - cron: "30 17 * * 5", + cron: "30 18 * * 0", class: "WeeklySummaryEmailJob", - description: "Sends weekly coding summaries on Fridays at 17:30 GMT." + description: "Sends weekly coding summaries on Sundays at 18:30 GMT." }, geocode_users_without_country: { diff --git a/test/controllers/api/hackatime/v1/hackatime_controller_test.rb b/test/controllers/api/hackatime/v1/hackatime_controller_test.rb index d978864..77ab122 100644 --- a/test/controllers/api/hackatime/v1/hackatime_controller_test.rb +++ b/test/controllers/api/hackatime/v1/hackatime_controller_test.rb @@ -29,6 +29,108 @@ class Api::Hackatime::V1::HackatimeControllerTest < ActionDispatch::IntegrationT assert_equal "coding", heartbeat.category end + test "single heartbeat resolves <> from existing heartbeats" do + user = User.create!(timezone: "UTC") + api_key = user.api_keys.create!(name: "primary") + # Seed a prior heartbeat with a known language + user.heartbeats.create!( + entity: "src/old.rb", + type: "file", + category: "coding", + time: 1.hour.ago.to_f, + language: "Ruby", + source_type: :direct_entry + ) + + payload = { + entity: "src/main.rb", + plugin: "vscode/1.0.0", + project: "hackatime", + time: Time.current.to_f, + type: "file", + language: "<>" + } + + assert_difference("Heartbeat.count", 1) do + post "/api/hackatime/v1/users/current/heartbeats", + params: payload.to_json, + headers: { + "Authorization" => "Bearer #{api_key.token}", + "CONTENT_TYPE" => "text/plain" + } + end + + assert_response :accepted + heartbeat = Heartbeat.order(:id).last + assert_equal "Ruby", heartbeat.language + end + + test "bulk heartbeat resolves <> from previous heartbeat in same batch" do + user = User.create!(timezone: "UTC") + api_key = user.api_keys.create!(name: "primary") + + now = Time.current.to_f + payload = [ + { + entity: "src/first.rb", + plugin: "vscode/1.0.0", + project: "hackatime", + time: now - 2, + type: "file", + language: "Python" + }, + { + entity: "src/second.rb", + plugin: "vscode/1.0.0", + project: "hackatime", + time: now - 1, + type: "file", + language: "<>" + } + ] + + assert_difference("Heartbeat.count", 2) do + post "/api/hackatime/v1/users/current/heartbeats.bulk", + params: payload.to_json, + headers: { + "Authorization" => "Bearer #{api_key.token}", + "CONTENT_TYPE" => "application/json" + } + end + + assert_response :created + heartbeats = Heartbeat.order(:id).last(2) + assert_equal "Python", heartbeats.first.language + assert_equal "Python", heartbeats.last.language + end + + test "single heartbeat with <> and no prior heartbeats stores nil language" do + user = User.create!(timezone: "UTC") + api_key = user.api_keys.create!(name: "primary") + + payload = { + entity: "src/main.rb", + plugin: "vscode/1.0.0", + project: "hackatime", + time: Time.current.to_f, + type: "file", + language: "<>" + } + + assert_difference("Heartbeat.count", 1) do + post "/api/hackatime/v1/users/current/heartbeats", + params: payload.to_json, + headers: { + "Authorization" => "Bearer #{api_key.token}", + "CONTENT_TYPE" => "text/plain" + } + end + + assert_response :accepted + heartbeat = Heartbeat.order(:id).last + assert_nil heartbeat.language + end + test "bulk heartbeat normalizes permitted params" do user = User.create!(timezone: "UTC") api_key = user.api_keys.create!(name: "primary")