Weekly summary emails + move off Loops (#998)

* Weekly summary emails + move off Loops

* Remove unused asset
This commit is contained in:
Mahad Kalam 2026-02-23 22:15:02 +00:00 committed by GitHub
parent 5bd4b7b0c7
commit 2816314df9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 658 additions and 50 deletions

View file

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

View file

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

View 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

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

View file

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

View file

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

View 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

View file

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

View 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

View file

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

View file

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

View file

@ -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}" %>

View file

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

View file

@ -1 +1,4 @@
<%= yield %>
---
15 Falls Road, Shelburne, VT 05482, United States

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

View file

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

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

View 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 %>

View 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 %>

View file

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

View file

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

View file

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

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

View file

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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