mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
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:
parent
607480ff8d
commit
922e7384c0
13 changed files with 202 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue