mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 23:32:53 +00:00
Weekly summary emails + move off Loops (#998)
* Weekly summary emails + move off Loops * Remove unused asset
This commit is contained in:
parent
5bd4b7b0c7
commit
2816314df9
33 changed files with 658 additions and 50 deletions
|
|
@ -31,9 +31,6 @@ SMTP_PASSWORD=replace_with_your_smtp_password
|
|||
SMTP_ADDRESS=replace_with_your_smtp_address
|
||||
SMTP_PORT=replace_with_your_smtp_port
|
||||
|
||||
# some emails are sent via loops.so– again, not needed for local development
|
||||
LOOPS_API_KEY=your_loops_api_key_here
|
||||
|
||||
# Sentry DSN for error tracking
|
||||
SENTRY_DSN=your_sentry_dsn_here
|
||||
|
||||
|
|
@ -59,4 +56,4 @@ 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
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class Settings::BaseController < InertiaController
|
|||
{
|
||||
"profile" => "Users/Settings/Profile",
|
||||
"integrations" => "Users/Settings/Integrations",
|
||||
"notifications" => "Users/Settings/Notifications",
|
||||
"access" => "Users/Settings/Access",
|
||||
"goals" => "Users/Settings/Goals",
|
||||
"badges" => "Users/Settings/Badges",
|
||||
|
|
@ -81,6 +82,7 @@ class Settings::BaseController < InertiaController
|
|||
section_paths: {
|
||||
profile: my_settings_profile_path,
|
||||
integrations: my_settings_integrations_path,
|
||||
notifications: my_settings_notifications_path,
|
||||
access: my_settings_access_path,
|
||||
goals: my_settings_goals_path,
|
||||
badges: my_settings_badges_path,
|
||||
|
|
@ -89,7 +91,7 @@ class Settings::BaseController < InertiaController
|
|||
},
|
||||
page_title: (@is_own_settings ? "My Settings" : "Settings | #{@user.display_name}"),
|
||||
heading: (@is_own_settings ? "Settings" : "Settings for #{@user.display_name}"),
|
||||
subheading: "Manage your profile, integrations, access, goals, and data tools.",
|
||||
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,
|
||||
|
|
@ -101,6 +103,7 @@ class Settings::BaseController < InertiaController
|
|||
username: @user.username,
|
||||
theme: @user.theme,
|
||||
uses_slack_status: @user.uses_slack_status,
|
||||
weekly_summary_email_enabled: @user.weekly_summary_email_enabled,
|
||||
hackatime_extension_text_type: @user.hackatime_extension_text_type,
|
||||
allow_public_stats_lookup: @user.allow_public_stats_lookup,
|
||||
trust_level: @user.trust_level,
|
||||
|
|
|
|||
29
app/controllers/settings/notifications_controller.rb
Normal file
29
app/controllers/settings/notifications_controller.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
class Settings::NotificationsController < Settings::BaseController
|
||||
def show
|
||||
render_notifications
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update(notifications_params)
|
||||
PosthogService.capture(@user, "settings_updated", { fields: notifications_params.keys })
|
||||
redirect_to my_settings_notifications_path, notice: "Settings updated successfully"
|
||||
else
|
||||
flash.now[:error] = @user.errors.full_messages.to_sentence.presence || "Failed to update settings"
|
||||
render_notifications(status: :unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_notifications(status: :ok)
|
||||
render_settings_page(
|
||||
active_section: "notifications",
|
||||
settings_update_path: my_settings_notifications_path,
|
||||
status: status
|
||||
)
|
||||
end
|
||||
|
||||
def notifications_params
|
||||
params.require(:user).permit(:weekly_summary_email_enabled)
|
||||
end
|
||||
end
|
||||
86
app/javascript/pages/Users/Settings/Notifications.svelte
Normal file
86
app/javascript/pages/Users/Settings/Notifications.svelte
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<script lang="ts">
|
||||
import { Checkbox } from "bits-ui";
|
||||
import { onMount } from "svelte";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import SettingsShell from "./Shell.svelte";
|
||||
import type { NotificationsPageProps } from "./types";
|
||||
|
||||
let {
|
||||
active_section,
|
||||
section_paths,
|
||||
page_title,
|
||||
heading,
|
||||
subheading,
|
||||
settings_update_path,
|
||||
user,
|
||||
errors,
|
||||
admin_tools,
|
||||
}: NotificationsPageProps = $props();
|
||||
|
||||
let csrfToken = $state("");
|
||||
let weeklySummaryEmailEnabled = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
weeklySummaryEmailEnabled = user.weekly_summary_email_enabled;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
csrfToken =
|
||||
document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
?.getAttribute("content") || "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<SettingsShell
|
||||
{active_section}
|
||||
{section_paths}
|
||||
{page_title}
|
||||
{heading}
|
||||
{subheading}
|
||||
{errors}
|
||||
{admin_tools}
|
||||
>
|
||||
<div class="space-y-8">
|
||||
<section id="user_email_notifications">
|
||||
<h2 class="text-xl font-semibold text-surface-content">
|
||||
Email Notifications
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Control which product emails Hackatime sends to your linked email
|
||||
addresses.
|
||||
</p>
|
||||
|
||||
<form method="post" action={settings_update_path} class="mt-4 space-y-4">
|
||||
<input type="hidden" name="_method" value="patch" />
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
|
||||
<div id="user_weekly_summary_email">
|
||||
<label class="flex items-center gap-3 text-sm text-surface-content">
|
||||
<input
|
||||
type="hidden"
|
||||
name="user[weekly_summary_email_enabled]"
|
||||
value="0"
|
||||
/>
|
||||
<Checkbox.Root
|
||||
bind:checked={weeklySummaryEmailEnabled}
|
||||
name="user[weekly_summary_email_enabled]"
|
||||
value="1"
|
||||
class="inline-flex h-4 w-4 min-w-4 items-center justify-center rounded border border-surface-200 bg-darker text-on-primary transition-colors data-[state=checked]:border-primary data-[state=checked]:bg-primary"
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<span class={checked ? "text-[10px]" : "hidden"}>✓</span>
|
||||
{/snippet}
|
||||
</Checkbox.Root>
|
||||
Weekly coding summary email (sent Fridays at 5:30 PM GMT)
|
||||
</label>
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
Includes your weekly coding time, top projects, and top languages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit">Save notification settings</Button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</SettingsShell>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export type SectionId =
|
||||
| "profile"
|
||||
| "integrations"
|
||||
| "notifications"
|
||||
| "access"
|
||||
| "goals"
|
||||
| "badges"
|
||||
|
|
@ -61,6 +62,7 @@ export type UserProps = {
|
|||
username?: string | null;
|
||||
theme: string;
|
||||
uses_slack_status: boolean;
|
||||
weekly_summary_email_enabled: boolean;
|
||||
hackatime_extension_text_type: string;
|
||||
allow_public_stats_lookup: boolean;
|
||||
trust_level: string;
|
||||
|
|
@ -255,6 +257,11 @@ export type AccessPageProps = SettingsCommonProps & {
|
|||
config_file: ConfigFileProps;
|
||||
};
|
||||
|
||||
export type NotificationsPageProps = SettingsCommonProps & {
|
||||
settings_update_path: string;
|
||||
user: UserProps;
|
||||
};
|
||||
|
||||
export type GoalsPageProps = SettingsCommonProps & {
|
||||
settings_update_path: string;
|
||||
create_goal_path: string;
|
||||
|
|
@ -298,6 +305,12 @@ export const buildSections = (sectionPaths: SectionPaths, adminVisible: boolean)
|
|||
blurb: "Slack status, GitHub link, and email sign-in addresses.",
|
||||
path: sectionPaths.integrations,
|
||||
},
|
||||
{
|
||||
id: "notifications" as SectionId,
|
||||
label: "Notifications",
|
||||
blurb: "Email notifications and weekly summary preferences.",
|
||||
path: sectionPaths.notifications,
|
||||
},
|
||||
{
|
||||
id: "access" as SectionId,
|
||||
label: "Access",
|
||||
|
|
@ -350,6 +363,8 @@ const hashSectionMap: Record<string, SectionId> = {
|
|||
user_slack_notifications: "integrations",
|
||||
user_github_account: "integrations",
|
||||
user_email_addresses: "integrations",
|
||||
user_email_notifications: "notifications",
|
||||
user_weekly_summary_email: "notifications",
|
||||
user_stats_badges: "badges",
|
||||
user_markscribe: "badges",
|
||||
user_heatmap: "badges",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
class UpdateAirtableUserDataJob < ApplicationJob
|
||||
queue_as :latency_5m
|
||||
|
||||
Table = Norairrecord.table(ENV["LOOPS_AIRTABLE_PAT"], "app6VcLJoYFbDdGWK", "tblnzmotZ55MFBfV4")
|
||||
Table = Norairrecord.table(ENV["cccccbceufnn"], "app6VcLJoYFbDdGWK", "tblnzmotZ55MFBfV4")
|
||||
|
||||
def perform
|
||||
users_with_heartbeats.includes(:email_addresses).find_in_batches(batch_size: 100) do |batch|
|
||||
|
|
|
|||
33
app/jobs/weekly_summary_email_job.rb
Normal file
33
app/jobs/weekly_summary_email_job.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
class WeeklySummaryEmailJob < ApplicationJob
|
||||
queue_as :literally_whenever
|
||||
|
||||
def perform(reference_time = Time.current)
|
||||
now_utc = reference_time.utc
|
||||
return unless send_window?(now_utc)
|
||||
|
||||
User.where(weekly_summary_email_enabled: true).find_each do |user|
|
||||
recipient_email = user.email_addresses.order(:id).pick(:email)
|
||||
next if recipient_email.blank?
|
||||
|
||||
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
|
||||
|
||||
WeeklySummaryMailer.weekly_summary(
|
||||
user,
|
||||
recipient_email: recipient_email,
|
||||
starts_at: starts_at_local.utc,
|
||||
ends_at: ends_at_local.utc
|
||||
).deliver_now
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Weekly summary email failed for user #{user.id}: #{e.class} #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_window?(time)
|
||||
time.friday? && time.hour == 17 && time.min == 30
|
||||
end
|
||||
end
|
||||
|
|
@ -1,18 +1,4 @@
|
|||
class LoopsMailer < ApplicationMailer
|
||||
if Rails.env.development? && ENV["LOOPS_API_KEY"].nil?
|
||||
self.delivery_method = :letter_opener
|
||||
else
|
||||
self.delivery_method = :smtp
|
||||
self.smtp_settings = {
|
||||
address: "smtp.loops.so",
|
||||
port: 587,
|
||||
user_name: "loops",
|
||||
password: ENV["LOOPS_API_KEY"],
|
||||
authentication: "plain",
|
||||
enable_starttls: true
|
||||
}
|
||||
end
|
||||
|
||||
def sign_in_email(email, token)
|
||||
@email = email
|
||||
@token = token
|
||||
|
|
@ -20,6 +6,7 @@ class LoopsMailer < ApplicationMailer
|
|||
|
||||
mail(
|
||||
to: @email,
|
||||
subject: "Your Hackatime sign-in link"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
58
app/mailers/weekly_summary_mailer.rb
Normal file
58
app/mailers/weekly_summary_mailer.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
class WeeklySummaryMailer < ApplicationMailer
|
||||
helper :application
|
||||
|
||||
def weekly_summary(user, recipient_email:, starts_at:, ends_at:)
|
||||
@user = user
|
||||
user_timezone = ActiveSupport::TimeZone[@user.timezone]
|
||||
@timezone = user_timezone || ActiveSupport::TimeZone["UTC"]
|
||||
@timezone_label = user_timezone ? @user.timezone : @timezone.tzinfo.identifier
|
||||
@starts_at = starts_at.utc
|
||||
@ends_at = ends_at.utc
|
||||
@starts_at_local = @starts_at.in_time_zone(@timezone)
|
||||
@ends_at_local = @ends_at.in_time_zone(@timezone)
|
||||
@subject_period_label = "#{@starts_at_local.strftime("%b %-d")} - #{@ends_at_local.strftime("%b %-d, %Y")}"
|
||||
@period_label = "#{@subject_period_label}"
|
||||
|
||||
coding_heartbeats = @user.heartbeats
|
||||
.coding_only
|
||||
.where(time: @starts_at.to_f...@ends_at.to_f)
|
||||
|
||||
@total_seconds = coding_heartbeats.duration_seconds
|
||||
@daily_average_seconds = (@total_seconds / 7.0).round
|
||||
@total_heartbeats = coding_heartbeats.count
|
||||
@active_days = active_days_count(coding_heartbeats)
|
||||
@top_projects = breakdown(coding_heartbeats, :project)
|
||||
@top_languages = breakdown(coding_heartbeats, :language)
|
||||
|
||||
mail(
|
||||
to: recipient_email,
|
||||
subject: "Your Hackatime weekly summary (#{@subject_period_label})"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def breakdown(scope, column, limit: 5)
|
||||
scope.group(column)
|
||||
.duration_seconds
|
||||
.sort_by { |_name, seconds| -seconds.to_i }
|
||||
.first(limit)
|
||||
.map do |name, seconds|
|
||||
{
|
||||
name: name.presence || "Other",
|
||||
seconds: seconds.to_i,
|
||||
duration_label: ApplicationController.helpers.short_time_simple(seconds)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def active_days_count(scope)
|
||||
timezone = @timezone_label
|
||||
timezone_sql = ActiveRecord::Base.connection.quote(timezone)
|
||||
scope.where.not(time: nil)
|
||||
.distinct
|
||||
.count(Arel.sql("DATE(to_timestamp(time) AT TIME ZONE #{timezone_sql})"))
|
||||
rescue StandardError
|
||||
scope.where.not(time: nil).pluck(:time).map { |time| Time.at(time).in_time_zone(timezone).to_date }.uniq.count
|
||||
end
|
||||
end
|
||||
|
|
@ -26,6 +26,7 @@ class User < ApplicationRecord
|
|||
|
||||
attribute :allow_public_stats_lookup, :boolean, default: true
|
||||
attribute :default_timezone_leaderboard, :boolean, default: true
|
||||
attribute :weekly_summary_email_enabled, :boolean, default: true
|
||||
|
||||
def country_name
|
||||
ISO3166::Country.new(country_code).common_name
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<h1>Verify your email address for Hackatime</h1>
|
||||
<p>Hi <%= h(@user.display_name) %>,</p>
|
||||
<p>You've requested to add <%= @verification_request.email %> to your Hackatime account. Click the link below to verify this email address:</p>
|
||||
<p>
|
||||
<%= link_to 'Verify email address', @verification_url %>
|
||||
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
|
||||
Verify your email address
|
||||
</h1>
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
|
||||
Hi <%= h(@user.display_name) %>, please confirm that you want to add
|
||||
<strong><%= @verification_request.email %></strong> to your Hackatime account.
|
||||
</p>
|
||||
|
||||
<%= render "shared/mailer/button", url: @verification_url, label: "Verify email address" %>
|
||||
|
||||
<p style="font-size: 14px; line-height: 20px; color: #64748b; margin: 24px 0 0 0;">
|
||||
If you didn't request this change, you can ignore this email.
|
||||
</p>
|
||||
<p>This link will expire in 30 minutes and can only be used once.</p>
|
||||
<p>If you didn't request this email, you can safely ignore it.</p>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<h1>Your heartbeat export is ready</h1>
|
||||
<p>Hi <%= h(@user.display_name) %>,</p>
|
||||
<p>Your Hackatime heartbeat export has been generated.</p>
|
||||
<p>
|
||||
<a href="<%= @download_url %>">Download <%= @filename %></a>
|
||||
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
|
||||
Your heartbeat export is ready
|
||||
</h1>
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
|
||||
Hi <%= h(@user.display_name) %>, your requested Hackatime heartbeat export is ready to download.
|
||||
</p>
|
||||
<p>This link will stop working after 7 days.</p>
|
||||
|
||||
<%= render "shared/mailer/button", url: @download_url, label: "Download #{@filename}" %>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,22 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<style>
|
||||
/* Email styles need to be inline */
|
||||
</style>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
<body style="margin: 0; padding: 0; background-color: #f8fafc; color: #0f172a; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.5;">
|
||||
<div style="width: 100%; padding: 20px;">
|
||||
<div style="width: 100%; max-width: 600px; margin: 0 auto; background-color: #ffffff;">
|
||||
<div style="padding: 32px 24px;">
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
||||
<div style="padding: 24px; border-top: 1px solid #e2e8f0; text-align: center; font-size: 12px; color: #64748b;">
|
||||
<% footer_icon_url = "#{root_url.chomp('/')}#{image_path('favicon.png')}" %>
|
||||
<%= image_tag(footer_icon_url, alt: "Hackatime", style: "width: 36px; height: 36px; opacity: 0.6; margin-bottom: 12px;") %>
|
||||
<p style="margin: 6px 0; color: #64748b;">15 Falls Road, Shelburne, VT 05482, United States</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
<%= yield %>
|
||||
|
||||
---
|
||||
15 Falls Road, Shelburne, VT 05482, United States
|
||||
|
|
|
|||
12
app/views/loops_mailer/sign_in_email.html.erb
Normal file
12
app/views/loops_mailer/sign_in_email.html.erb
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
|
||||
Your Hackatime sign-in link
|
||||
</h1>
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
|
||||
Use the secure link below to sign in to your account.
|
||||
</p>
|
||||
|
||||
<%= render "shared/mailer/button", url: @sign_in_url, label: "Sign in to Hackatime" %>
|
||||
|
||||
<p style="font-size: 14px; line-height: 20px; color: #64748b; margin: 24px 0 0 0;">
|
||||
If you did not request this email, you can safely ignore it.
|
||||
</p>
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"transactionalId": "cm86bg9vd00xuddjuh5u5mfuu",
|
||||
"email": "<%= @email %>",
|
||||
"dataVariables": {
|
||||
"auth_link": "<%= @sign_in_url %>"
|
||||
}
|
||||
}
|
||||
Your Hackatime sign-in link
|
||||
|
||||
Use this secure sign-in link to access your account:
|
||||
<%= @sign_in_url %>
|
||||
|
||||
For security, this link expires in 30 minutes and can only be used once.
|
||||
|
||||
If you didn't request this email, you can ignore it.
|
||||
|
|
|
|||
6
app/views/shared/mailer/_button.html.erb
Normal file
6
app/views/shared/mailer/_button.html.erb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<%# locals: (url:, label:) %>
|
||||
<div style="margin: 16px 0;">
|
||||
<a href="<%= url %>" style="display: inline-block; padding: 10px 16px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 14px; background-color: #4f46e5; color: #ffffff;">
|
||||
<%= label %>
|
||||
</a>
|
||||
</div>
|
||||
75
app/views/weekly_summary_mailer/weekly_summary.html.erb
Normal file
75
app/views/weekly_summary_mailer/weekly_summary.html.erb
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<h1 style="font-size: 24px; line-height: 32px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 8px 0 0 0;">
|
||||
Your coding recap
|
||||
</h1>
|
||||
<p style="font-size: 14px; line-height: 20px; color: #334155; margin: 8px 0 0 0;">
|
||||
<%= @period_label %>
|
||||
</p>
|
||||
<p style="font-size: 16px; line-height: 24px; color: #334155; margin: 16px 0 0 0;">
|
||||
Hi <%= h(@user.display_name) %>, here is your weekly coding snapshot from Hackatime.
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 24px; border: 1px solid #dbe4ee; border-radius: 12px; overflow: hidden; background-color: #ffffff;">
|
||||
<div style="padding: 18px 20px; border-bottom: 1px solid #e2e8f0; background-color: #f8fafc;">
|
||||
<p style="font-size: 13px; line-height: 18px; color: #64748b; margin: 0;">Total coding time</p>
|
||||
<p style="font-size: 34px; line-height: 38px; letter-spacing: -0.02em; font-weight: 700; color: #0f172a; margin: 8px 0 0 0;">
|
||||
<%= short_time_simple(@total_seconds) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="width: 33.33%; padding: 14px 16px; border-right: 1px solid #e2e8f0; vertical-align: top;">
|
||||
<p style="font-size: 12px; line-height: 16px; color: #64748b; margin: 0;">Daily average</p>
|
||||
<p style="font-size: 18px; line-height: 24px; font-weight: 600; color: #0f172a; margin: 4px 0 0 0;"><%= short_time_simple(@daily_average_seconds) %></p>
|
||||
</td>
|
||||
<td style="width: 33.33%; padding: 14px 16px; border-right: 1px solid #e2e8f0; vertical-align: top;">
|
||||
<p style="font-size: 12px; line-height: 16px; color: #64748b; margin: 0;">Active days</p>
|
||||
<p style="font-size: 18px; line-height: 24px; font-weight: 600; color: #0f172a; margin: 4px 0 0 0;"><%= @active_days %>/7</p>
|
||||
</td>
|
||||
<td style="width: 33.33%; padding: 14px 16px; vertical-align: top;">
|
||||
<p style="font-size: 12px; line-height: 16px; color: #64748b; margin: 0;">Heartbeats</p>
|
||||
<p style="font-size: 18px; line-height: 24px; font-weight: 600; color: #0f172a; margin: 4px 0 0 0;"><%= @total_heartbeats %></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% if @total_seconds.zero? %>
|
||||
<div style="margin-top: 24px; padding: 14px 16px; border: 1px solid #e2e8f0; border-radius: 10px; background-color: #f8fafc;">
|
||||
<p style="font-size: 14px; line-height: 20px; color: #334155; margin: 0;">
|
||||
No coding activity was recorded this week. Start a new session and your next summary will include your project and language breakdown.
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div style="margin-top: 24px; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; background-color: #ffffff;">
|
||||
<p style="font-size: 15px; line-height: 20px; font-weight: 700; color: #0f172a; margin: 0;">Top projects</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse; margin-top: 10px;">
|
||||
<% @top_projects.each_with_index do |project, index| %>
|
||||
<tr>
|
||||
<td style="font-size: 14px; line-height: 20px; color: #334155; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
|
||||
<%= project[:name] %>
|
||||
</td>
|
||||
<td align="right" style="font-size: 14px; line-height: 20px; font-weight: 600; color: #0f172a; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
|
||||
<%= project[:duration_label] %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 16px; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; background-color: #ffffff;">
|
||||
<p style="font-size: 15px; line-height: 20px; font-weight: 700; color: #0f172a; margin: 0;">Top languages</p>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse; margin-top: 10px;">
|
||||
<% @top_languages.each_with_index do |language, index| %>
|
||||
<tr>
|
||||
<td style="font-size: 14px; line-height: 20px; color: #334155; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
|
||||
<%= language[:name] %>
|
||||
</td>
|
||||
<td align="right" style="font-size: 14px; line-height: 20px; font-weight: 600; color: #0f172a; padding: 10px 0; <%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
|
||||
<%= language[:duration_label] %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
22
app/views/weekly_summary_mailer/weekly_summary.text.erb
Normal file
22
app/views/weekly_summary_mailer/weekly_summary.text.erb
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
Your Hackatime weekly summary: <%= @period_label %>
|
||||
|
||||
Hi <%= h(@user.display_name) %>,
|
||||
|
||||
Total coding time: <%= short_time_simple(@total_seconds) %>
|
||||
Daily average: <%= short_time_simple(@daily_average_seconds) %>
|
||||
Active days: <%= @active_days %>/7
|
||||
Heartbeats: <%= @total_heartbeats %>
|
||||
|
||||
<% if @total_seconds.zero? %>
|
||||
No coding activity was recorded this week.
|
||||
<% else %>
|
||||
Top projects:
|
||||
<% @top_projects.each do |project| %>
|
||||
- <%= project[:name] %>: <%= project[:duration_label] %>
|
||||
<% end %>
|
||||
|
||||
Top languages:
|
||||
<% @top_languages.each do |language| %>
|
||||
- <%= language[:name] %>: <%= language[:duration_label] %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
@ -114,6 +114,11 @@ Rails.application.configure do
|
|||
cron: "*/5 * * * *",
|
||||
class: "HeartbeatImportSourceSchedulerJob"
|
||||
},
|
||||
weekly_summary_email: {
|
||||
cron: "* * * * *",
|
||||
class: "WeeklySummaryEmailJob",
|
||||
description: "Sends weekly coding summaries on Fridays at 17:30 GMT."
|
||||
},
|
||||
|
||||
geocode_users_without_country: {
|
||||
cron: "7 * * * *",
|
||||
|
|
|
|||
|
|
@ -145,6 +145,8 @@ Rails.application.routes.draw do
|
|||
patch "my/settings/profile", to: "settings/profile#update"
|
||||
get "my/settings/integrations", to: "settings/integrations#show", as: :my_settings_integrations
|
||||
patch "my/settings/integrations", to: "settings/integrations#update"
|
||||
get "my/settings/notifications", to: "settings/notifications#show", as: :my_settings_notifications
|
||||
patch "my/settings/notifications", to: "settings/notifications#update"
|
||||
get "my/settings/access", to: "settings/access#show", as: :my_settings_access
|
||||
patch "my/settings/access", to: "settings/access#update"
|
||||
get "my/settings/goals", to: "settings/goals#show", as: :my_settings_goals
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
class AddWeeklySummaryEmailEnabledToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :weekly_summary_email_enabled, :boolean, default: true, null: false
|
||||
end
|
||||
end
|
||||
3
db/schema.rb
generated
3
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_02_23_134705) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_02_23_212054) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_stat_statements"
|
||||
|
|
@ -647,6 +647,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_23_134705) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.string "username"
|
||||
t.boolean "uses_slack_status", default: false, null: false
|
||||
t.boolean "weekly_summary_email_enabled", default: true, null: false
|
||||
t.index ["github_uid", "github_access_token"], name: "index_users_on_github_uid_and_access_token"
|
||||
t.index ["github_uid"], name: "index_users_on_github_uid"
|
||||
t.index ["slack_uid"], name: "index_users_on_slack_uid", unique: true
|
||||
|
|
|
|||
|
|
@ -78,11 +78,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
|||
|
||||
oauth_path = "/oauth/authorize?client_id=test&response_type=code"
|
||||
|
||||
# LoopsMailer forces SMTP delivery even in test; temporarily override
|
||||
original_delivery_method = LoopsMailer.delivery_method
|
||||
LoopsMailer.delivery_method = :test
|
||||
post email_auth_path, params: { email: email, continue: oauth_path }
|
||||
LoopsMailer.delivery_method = original_delivery_method
|
||||
|
||||
assert_response :redirect
|
||||
|
||||
|
|
|
|||
26
test/controllers/settings_notifications_controller_test.rb
Normal file
26
test/controllers/settings_notifications_controller_test.rb
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
require "test_helper"
|
||||
|
||||
class SettingsNotificationsControllerTest < ActionDispatch::IntegrationTest
|
||||
fixtures :users
|
||||
|
||||
test "notifications show renders notifications settings page" do
|
||||
user = users(:one)
|
||||
sign_in_as(user)
|
||||
|
||||
get my_settings_notifications_path
|
||||
|
||||
assert_response :success
|
||||
assert_inertia_component "Users/Settings/Notifications"
|
||||
end
|
||||
|
||||
test "notifications update persists weekly summary email preference" do
|
||||
user = users(:one)
|
||||
sign_in_as(user)
|
||||
|
||||
patch my_settings_notifications_path, params: { user: { weekly_summary_email_enabled: "0" } }
|
||||
|
||||
assert_response :redirect
|
||||
assert_redirected_to my_settings_notifications_path
|
||||
assert_equal false, user.reload.weekly_summary_email_enabled
|
||||
end
|
||||
end
|
||||
79
test/jobs/weekly_summary_email_job_test.rb
Normal file
79
test/jobs/weekly_summary_email_job_test.rb
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
require "test_helper"
|
||||
|
||||
class WeeklySummaryEmailJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
ActionMailer::Base.deliveries.clear
|
||||
end
|
||||
|
||||
test "sends weekly summaries only for opted-in users with email at friday 17:30 utc" do
|
||||
enabled_user = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
|
||||
enabled_user.email_addresses.create!(email: "enabled-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
|
||||
create_coding_heartbeat(enabled_user, Time.utc(2026, 2, 21, 12, 0, 0), "project-enabled", "Ruby")
|
||||
create_coding_heartbeat(enabled_user, Time.utc(2026, 2, 21, 12, 20, 0), "project-enabled", "Ruby")
|
||||
|
||||
disabled_user = User.create!(timezone: "UTC", weekly_summary_email_enabled: false)
|
||||
disabled_user.email_addresses.create!(email: "disabled-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
|
||||
create_coding_heartbeat(disabled_user, Time.utc(2026, 2, 21, 13, 0, 0), "project-disabled", "Ruby")
|
||||
create_coding_heartbeat(disabled_user, Time.utc(2026, 2, 21, 13, 20, 0), "project-disabled", "Ruby")
|
||||
|
||||
user_without_email = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
|
||||
create_coding_heartbeat(user_without_email, Time.utc(2026, 2, 20, 14, 0, 0), "project-no-email", "Ruby")
|
||||
|
||||
reference_time = Time.utc(2026, 2, 27, 17, 30, 0) # Friday, 5:30 PM GMT
|
||||
|
||||
assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
|
||||
WeeklySummaryEmailJob.perform_now(reference_time)
|
||||
end
|
||||
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
assert_equal [ enabled_user.email_addresses.first.email ], mail.to
|
||||
assert_equal "Your Hackatime weekly summary (Feb 16 - Feb 23, 2026)", mail.subject
|
||||
assert_includes mail.text_part.body.decoded, "Top projects:"
|
||||
end
|
||||
|
||||
test "uses previous local calendar week for each user's timezone" do
|
||||
user = User.create!(timezone: "America/Los_Angeles", weekly_summary_email_enabled: true)
|
||||
user.email_addresses.create!(email: "la-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
|
||||
create_coding_heartbeat(user, Time.utc(2026, 2, 16, 8, 30, 0), "local-week-in-range", "Ruby")
|
||||
create_coding_heartbeat(user, Time.utc(2026, 2, 16, 8, 50, 0), "local-week-in-range", "Ruby")
|
||||
create_coding_heartbeat(user, Time.utc(2026, 2, 16, 7, 30, 0), "local-week-out-of-range", "Ruby")
|
||||
|
||||
reference_time = Time.utc(2026, 2, 27, 17, 30, 0)
|
||||
|
||||
assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
|
||||
WeeklySummaryEmailJob.perform_now(reference_time)
|
||||
end
|
||||
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
assert_equal "Your Hackatime weekly summary (Feb 16 - Feb 23, 2026)", mail.subject
|
||||
assert_includes mail.text_part.body.decoded, "Feb 16 - Feb 23, 2026"
|
||||
assert_includes mail.text_part.body.decoded, "local-week-in-range"
|
||||
assert_not_includes mail.text_part.body.decoded, "local-week-out-of-range"
|
||||
end
|
||||
|
||||
test "does not send weekly summaries outside friday 17:30 utc" do
|
||||
user = User.create!(timezone: "UTC", weekly_summary_email_enabled: true)
|
||||
user.email_addresses.create!(email: "outside-window-#{SecureRandom.hex(4)}@example.com", source: :signing_in)
|
||||
create_coding_heartbeat(user, Time.utc(2026, 2, 20, 12, 0, 0), "project-outside", "Ruby")
|
||||
|
||||
reference_time = Time.utc(2026, 2, 27, 17, 29, 0)
|
||||
|
||||
assert_no_difference -> { ActionMailer::Base.deliveries.count } do
|
||||
WeeklySummaryEmailJob.perform_now(reference_time)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_coding_heartbeat(user, time, project, language)
|
||||
user.heartbeats.create!(
|
||||
entity: "src/#{project}.rb",
|
||||
type: "file",
|
||||
category: "coding",
|
||||
time: time.to_f,
|
||||
project: project,
|
||||
language: language,
|
||||
source_type: :test_entry
|
||||
)
|
||||
end
|
||||
end
|
||||
15
test/mailers/loops_mailer_test.rb
Normal file
15
test/mailers/loops_mailer_test.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
require "test_helper"
|
||||
|
||||
class LoopsMailerTest < ActionMailer::TestCase
|
||||
test "sign_in_email renders standard erb html and text versions" do
|
||||
token = "test-sign-in-token"
|
||||
recipient = "loops-login-#{SecureRandom.hex(4)}@example.com"
|
||||
|
||||
mail = LoopsMailer.sign_in_email(recipient, token)
|
||||
|
||||
assert_equal [ recipient ], mail.to
|
||||
assert_equal "Your Hackatime sign-in link", mail.subject
|
||||
assert_includes mail.html_part.body.decoded, "Sign in to Hackatime"
|
||||
assert_includes mail.text_part.body.decoded, "/auth/token/#{token}"
|
||||
end
|
||||
end
|
||||
13
test/mailers/previews/email_verification_mailer_preview.rb
Normal file
13
test/mailers/previews/email_verification_mailer_preview.rb
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
class EmailVerificationMailerPreview < ActionMailer::Preview
|
||||
def verify_email
|
||||
user = User.first || User.new(username: "preview_user", timezone: "UTC")
|
||||
verification_request = EmailVerificationRequest.new(
|
||||
user: user,
|
||||
email: "newemail@example.com",
|
||||
token: "preview-verification-token",
|
||||
expires_at: 30.minutes.from_now
|
||||
)
|
||||
|
||||
EmailVerificationMailer.verify_email(verification_request)
|
||||
end
|
||||
end
|
||||
17
test/mailers/previews/heartbeat_export_mailer_preview.rb
Normal file
17
test/mailers/previews/heartbeat_export_mailer_preview.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
class HeartbeatExportMailerPreview < ActionMailer::Preview
|
||||
def export_ready
|
||||
user = User.first || User.new(username: "preview_user", timezone: "UTC")
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new("preview"),
|
||||
filename: "heartbeats_export.zip",
|
||||
content_type: "application/zip"
|
||||
)
|
||||
|
||||
HeartbeatExportMailer.export_ready(
|
||||
user,
|
||||
recipient_email: "user@example.com",
|
||||
blob_signed_id: blob.signed_id,
|
||||
filename: "heartbeats_export.zip"
|
||||
)
|
||||
end
|
||||
end
|
||||
5
test/mailers/previews/loops_mailer_preview.rb
Normal file
5
test/mailers/previews/loops_mailer_preview.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
class LoopsMailerPreview < ActionMailer::Preview
|
||||
def sign_in_email
|
||||
LoopsMailer.sign_in_email("user@example.com", "preview-token-abc123")
|
||||
end
|
||||
end
|
||||
14
test/mailers/previews/weekly_summary_mailer_preview.rb
Normal file
14
test/mailers/previews/weekly_summary_mailer_preview.rb
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
class WeeklySummaryMailerPreview < ActionMailer::Preview
|
||||
def weekly_summary
|
||||
user = User.first || User.new(username: "preview_user", timezone: "UTC")
|
||||
ends_at = Time.current.beginning_of_week
|
||||
starts_at = ends_at - 7.days
|
||||
|
||||
WeeklySummaryMailer.weekly_summary(
|
||||
user,
|
||||
recipient_email: "user@example.com",
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at
|
||||
)
|
||||
end
|
||||
end
|
||||
51
test/mailers/weekly_summary_mailer_test.rb
Normal file
51
test/mailers/weekly_summary_mailer_test.rb
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
require "test_helper"
|
||||
|
||||
class WeeklySummaryMailerTest < ActionMailer::TestCase
|
||||
setup do
|
||||
@user = User.create!(
|
||||
timezone: "UTC",
|
||||
weekly_summary_email_enabled: true
|
||||
)
|
||||
@recipient_email = "weekly-mailer-#{SecureRandom.hex(4)}@example.com"
|
||||
@user.email_addresses.create!(email: @recipient_email, source: :signing_in)
|
||||
end
|
||||
|
||||
test "weekly_summary renders coding recap and top lists" do
|
||||
create_coding_heartbeat(Time.utc(2026, 2, 24, 10, 0, 0), "hackatime-web", "Ruby")
|
||||
create_coding_heartbeat(Time.utc(2026, 2, 25, 11, 0, 0), "hackatime-web", "Ruby")
|
||||
create_coding_heartbeat(Time.utc(2026, 2, 26, 11, 30, 0), "ops-tools", "JavaScript")
|
||||
|
||||
starts_at = Time.utc(2026, 2, 20, 17, 30, 0)
|
||||
ends_at = Time.utc(2026, 2, 27, 17, 30, 0)
|
||||
|
||||
mail = WeeklySummaryMailer.weekly_summary(
|
||||
@user,
|
||||
recipient_email: @recipient_email,
|
||||
starts_at: starts_at,
|
||||
ends_at: ends_at
|
||||
)
|
||||
|
||||
assert_equal [ @recipient_email ], mail.to
|
||||
assert_equal "Your Hackatime weekly summary (Feb 20 - Feb 27, 2026)", mail.subject
|
||||
assert_includes mail.html_part.body.decoded, "Your coding recap"
|
||||
assert_includes mail.html_part.body.decoded, "Top projects"
|
||||
assert_includes mail.text_part.body.decoded, "Feb 20 - Feb 27, 2026"
|
||||
assert_includes mail.text_part.body.decoded, "Top languages:"
|
||||
assert_includes mail.text_part.body.decoded, "hackatime-web"
|
||||
assert_not_includes mail.html_part.body.decoded.downcase, "gradient"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_coding_heartbeat(time, project, language)
|
||||
@user.heartbeats.create!(
|
||||
entity: "src/#{project}.rb",
|
||||
type: "file",
|
||||
category: "coding",
|
||||
time: time.to_f,
|
||||
project: project,
|
||||
language: language,
|
||||
source_type: :test_entry
|
||||
)
|
||||
end
|
||||
end
|
||||
35
test/system/settings/notifications_settings_test.rb
Normal file
35
test/system/settings/notifications_settings_test.rb
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
require "application_system_test_case"
|
||||
require_relative "test_helpers"
|
||||
|
||||
class NotificationsSettingsTest < ApplicationSystemTestCase
|
||||
include SettingsSystemTestHelpers
|
||||
|
||||
setup do
|
||||
@user = User.create!(timezone: "UTC")
|
||||
sign_in_as(@user)
|
||||
end
|
||||
|
||||
test "notifications settings page renders weekly summary section" do
|
||||
assert_settings_page(
|
||||
path: my_settings_notifications_path,
|
||||
marker_text: "Email Notifications"
|
||||
)
|
||||
|
||||
assert_text "Weekly coding summary email"
|
||||
end
|
||||
|
||||
test "notifications settings updates weekly summary email preference" do
|
||||
@user.update!(weekly_summary_email_enabled: true)
|
||||
|
||||
visit my_settings_notifications_path
|
||||
|
||||
within("#user_weekly_summary_email") do
|
||||
find("[role='checkbox']", wait: 10).click
|
||||
end
|
||||
|
||||
click_on "Save notification settings"
|
||||
|
||||
assert_text "Settings updated successfully"
|
||||
assert_equal false, @user.reload.weekly_summary_email_enabled
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue