Fix email from name/dates, login flash, wakatime_setup redirect, Sunday 6:30pm GMT (#1067)

* Smol fixes

* Map <<LAST_LANGUAGE>

* whoops

* Fix

* Move emails from Friday to Sunday
This commit is contained in:
Mahad Kalam 2026-03-13 10:53:57 +00:00 committed by GitHub
parent 607480ff8d
commit 922e7384c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 202 additions and 23 deletions

View file

@ -217,12 +217,27 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController
end || {}
end
LAST_LANGUAGE_SENTINEL = "<<LAST_LANGUAGE>>"
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 <<LAST_LANGUAGE>> 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string | null>(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<typeof setTimeout> | undefined;
const hideTimeoutId = setTimeout(() => {
flashHiding = true;
removeTimeoutId = setTimeout(() => {
flashVisible = false;
flashHiding = false;
}, flashExitDuration);
}, flashHideDelay);
return () => {
clearTimeout(hideTimeoutId);
if (removeTimeoutId) clearTimeout(removeTimeoutId);
};
});
</script>
<div class="landing-page min-h-screen w-full bg-darker text-surface-content">
<!-- Fixed Header -->
{#if flashVisible && flash.length > 0}
<div
class="fixed top-4 left-1/2 transform -translate-x-1/2 z-[60] w-full max-w-md px-4 space-y-2"
>
{#each flash as item}
<div
class={`flash-message shadow-lg flash-message--enter ${flashHiding ? "flash-message--leaving" : ""} ${item.class_name}`}
>
{item.message}
</div>
{/each}
</div>
{/if}
<!-- Fixed -->
<header
class="fixed top-0 w-full bg-darker/95 backdrop-blur-sm z-50 border-b border-surface-200/60"
>
@ -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
</Link>
</nav>
</div>

View file

@ -71,7 +71,7 @@
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
{/snippet}
</Checkbox.Root>
Weekly coding summary email (sent Fridays at 5:30 PM GMT)
Weekly coding summary email (sent Sundays at 6:30 PM GMT)
</label>
<p class="mt-2 text-xs text-muted">
Includes your weekly coding time, top projects, and top languages.

View file

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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,108 @@ class Api::Hackatime::V1::HackatimeControllerTest < ActionDispatch::IntegrationT
assert_equal "coding", heartbeat.category
end
test "single heartbeat resolves <<LAST_LANGUAGE>> 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: "<<LAST_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 <<LAST_LANGUAGE>> 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: "<<LAST_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 <<LAST_LANGUAGE>> 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: "<<LAST_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")