mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
Email layout v2 (#1015)
* Better emails! * Fix tests? * bit o' cleanup * add rant * pt2 * pt3 * Update tests * oop * man what on earth * ffs!!!!! * Revert "ffs!!!!!" This reverts commit b58bfed0f4c6288e95d0a111aeb3d7d7900ac9e7. * Revert "man what on earth" This reverts commit 8752cc2200eb3b852ea545d10ccbd555ab09d2b4. * Revert "Fix tests?" This reverts commit 810ebde73376ff7da0595e6b927f1b464d62b4a4. * Ignore external Google Fonts link in premailer
This commit is contained in:
parent
cb67e125d0
commit
d5d987a8f4
22 changed files with 468 additions and 130 deletions
|
|
@ -1,6 +1,10 @@
|
|||
# Omakase Ruby styling for Rails
|
||||
inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
||||
|
||||
AllCops:
|
||||
Exclude:
|
||||
- 'app/assets/stylesheets/**/*'
|
||||
|
||||
# Overwrite or add rules to create your own house style
|
||||
#
|
||||
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
|
||||
|
|
|
|||
2
Gemfile
2
Gemfile
|
|
@ -152,6 +152,8 @@ group :production do
|
|||
gem "cloudflare-rails"
|
||||
end
|
||||
|
||||
gem "premailer-rails"
|
||||
|
||||
gem "htmlcompressor", "~> 0.4.0"
|
||||
|
||||
gem "doorkeeper", "~> 5.8"
|
||||
|
|
|
|||
12
Gemfile.lock
12
Gemfile.lock
|
|
@ -144,6 +144,8 @@ GEM
|
|||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
css_parser (2.0.0)
|
||||
addressable
|
||||
csv (3.3.5)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
|
|
@ -231,6 +233,7 @@ GEM
|
|||
hashie (5.1.0)
|
||||
logger
|
||||
htmlcompressor (0.4.0)
|
||||
htmlentities (4.4.2)
|
||||
http (5.3.1)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
|
|
@ -373,6 +376,14 @@ GEM
|
|||
concurrent-ruby (~> 1)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
premailer (1.28.0)
|
||||
addressable
|
||||
css_parser (>= 1.19.0)
|
||||
htmlentities (>= 4.0.0)
|
||||
premailer-rails (1.12.0)
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.9.0)
|
||||
propshaft (1.3.1)
|
||||
|
|
@ -675,6 +686,7 @@ DEPENDENCIES
|
|||
paper_trail
|
||||
pg
|
||||
posthog-ruby
|
||||
premailer-rails
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
query_count
|
||||
|
|
|
|||
266
app/assets/stylesheets/mailer.css
Normal file
266
app/assets/stylesheets/mailer.css
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/*
|
||||
* Tailwind-named utility classes with static values for email client compatibility.
|
||||
*
|
||||
* "Wait, this is stupid. Can I not just use Tailwind directly?" AHAHAHAHAHA no. Believe me I tried.
|
||||
* (Well, technically you can, you just get super patchy support in email clients).
|
||||
*
|
||||
* The two big ones are:
|
||||
* - Tailwind 4 requires `oklch`, not supported in most email clients: https://www.caniemail.com/features/css-modern-color/
|
||||
* - Tailwind 4 requires CSS variables, which has less thatn 50% coverage: https://www.caniemail.com/features/css-variables/
|
||||
*/
|
||||
|
||||
.wrapper {
|
||||
padding: 1.25rem;
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
.container {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.section {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.max-w-\[600px\] {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-full {
|
||||
max-width: 100%;
|
||||
}
|
||||
.h-auto {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.m-0 {
|
||||
margin: 0;
|
||||
}
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.my-1 {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.my-4 {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.mt-0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
.mt-1 {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.mt-8 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.mb-0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ── Padding ── */
|
||||
.p-0 { padding: 0; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-5 { padding: 1.25rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
|
||||
.px-4 { padding-left: 1rem; padding-right: 1rem; }
|
||||
.px-5 { padding-left: 1.25rem; padding-right: 1.25rem; }
|
||||
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
|
||||
.pt-2 { padding-top: 0.5rem; }
|
||||
.pt-6 { padding-top: 1.5rem; }
|
||||
.pb-6 { padding-bottom: 1.5rem; }
|
||||
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
|
||||
.py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; }
|
||||
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
|
||||
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
|
||||
.py-5 { padding-top: 1.25rem; padding-bottom: 1.25rem; }
|
||||
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.font-normal {
|
||||
font-weight: 400;
|
||||
}
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.leading-5 {
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
.leading-6 {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
.leading-relaxed {
|
||||
line-height: 1.625;
|
||||
}
|
||||
.leading-tight {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.tracking-tight {
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.no-underline {
|
||||
text-decoration: none;
|
||||
}
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.align-top {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.text-white {
|
||||
color: #ffffff;
|
||||
}
|
||||
.text-black {
|
||||
color: #000000;
|
||||
}
|
||||
.text-gray-400 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: #6b7280;
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: #4b5563;
|
||||
}
|
||||
.text-gray-700 {
|
||||
color: #374151;
|
||||
}
|
||||
.text-gray-900 {
|
||||
color: #111827;
|
||||
}
|
||||
.text-indigo-600 {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.bg-gray-50 {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
.bg-gray-100 {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.bg-indigo-600 {
|
||||
background-color: #4f46e5;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
.border-t {
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid;
|
||||
border-top-color: #e5e7eb;
|
||||
}
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-style: solid;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* ── Border Radius ── */
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.rounded-md {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.rounded-xl {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -2,7 +2,10 @@ class WeeklySummaryEmailJob < ApplicationJob
|
|||
queue_as :literally_whenever
|
||||
|
||||
def perform(reference_time = Time.current)
|
||||
# See https://hackclub.slack.com/archives/D083UR1DR7V/p1772321709715969
|
||||
# Weekly summary delivery is intentionally disabled for now.
|
||||
# Context: https://hackclub.slack.com/archives/D083UR1DR7V/p1772321709715969
|
||||
# Keep this no-op until we explicitly decide to turn the campaign back on.
|
||||
reference_time
|
||||
|
||||
# now_utc = reference_time.utc
|
||||
# return unless send_window?(now_utc)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
|
||||
<h1 class="text-xl font-bold text-gray-900 tracking-tight mt-4 mb-0">
|
||||
Verify your email address
|
||||
</h1>
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
|
||||
<p class="text-base leading-relaxed text-gray-700 mt-4 mb-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;">
|
||||
<p class="text-sm leading-5 text-gray-400 mt-6 mb-0">
|
||||
If you didn't request this change, you can ignore this email.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
Verify your email address for Hackatime
|
||||
Hackatime — Verify Your Email
|
||||
==============================
|
||||
|
||||
Hi <%= h(@user.display_name) %>,
|
||||
|
||||
You've requested to add <%= @verification_request.email %> to your Hackatime account.
|
||||
Click the link below to verify this email address:
|
||||
Please confirm that you want to add <%= @verification_request.email %> to your Hackatime account:
|
||||
|
||||
<%= @verification_url %>
|
||||
|
||||
This link will expire in 30 minutes and can only be used once.
|
||||
This link expires in 30 minutes and can only be used once.
|
||||
|
||||
If you didn't request this email, you can safely ignore it.
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
|
||||
<h1 class="text-xl font-bold text-gray-900 tracking-tight mt-4 mb-0">
|
||||
Your heartbeat export is ready
|
||||
</h1>
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
|
||||
<p class="text-base leading-relaxed text-gray-700 mt-4 mb-0">
|
||||
Hi <%= h(@user.display_name) %>, your requested Hackatime heartbeat export is ready to download.
|
||||
</p>
|
||||
|
||||
<%= render "shared/mailer/button", url: @download_url, label: "Download #{@filename}" %>
|
||||
<p style="font-size: 14px; line-height: 20px; color: #64748b; margin: 24px 0 0 0;">
|
||||
<p class="text-sm leading-5 text-gray-400 mt-6 mb-0">
|
||||
This link will stop working after 7 days.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
Your heartbeat export is ready
|
||||
Hackatime — Your Export is Ready
|
||||
==================================
|
||||
|
||||
Hi <%= h(@user.display_name) %>,
|
||||
|
||||
Your Hackatime heartbeat export has been generated.
|
||||
Your heartbeat export (<%= @filename %>) is ready to download:
|
||||
|
||||
Download file: <%= @download_url %>
|
||||
Filename: <%= @filename %>
|
||||
<%= @download_url %>
|
||||
|
||||
This link will stop working after 7 days.
|
||||
This link will expire in 7 days.
|
||||
|
|
|
|||
|
|
@ -1,23 +1,38 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
</head>
|
||||
|
||||
<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;">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Spline+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" data-premailer="ignore">
|
||||
<%= stylesheet_link_tag "mailer" %>
|
||||
</head>
|
||||
<body class="m-0 p-0 bg-gray-50 text-gray-900 text-base leading-relaxed" style="font-family: 'Spline Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||
<div class="w-full py-5 px-5 mx-auto max-w-[600px]">
|
||||
<table width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left font-normal">
|
||||
<%= render "layouts/mailer/header" %>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="px-4 pt-2 pb-6">
|
||||
<%= 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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="px-4 py-5 text-center text-xs text-gray-400">
|
||||
<p class="my-1">15 Falls Road, Shelburne, VT 05482, United States</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
6
app/views/layouts/mailer/_header.html.erb
Normal file
6
app/views/layouts/mailer/_header.html.erb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<div class="px-4 text-left">
|
||||
<a href="<%= root_url %>" target="_blank">
|
||||
<% logo_url = "#{root_url.chomp('/')}#{image_path('favicon.png')}" %>
|
||||
<%= image_tag(logo_url, alt: "Hackatime", style: "height: 2rem", height: "32") %>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
<h1 style="font-size: 20px; line-height: 28px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 16px 0 0 0;">
|
||||
<h1 class="text-xl font-bold text-gray-900 tracking-tight mt-4 mb-0">
|
||||
Your Hackatime sign-in link
|
||||
</h1>
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #334155; margin: 16px 0 0 0;">
|
||||
<p class="text-base leading-relaxed text-gray-700 mt-4 mb-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;">
|
||||
<p class="text-sm leading-5 text-gray-400 mt-6 mb-0">
|
||||
If you did not request this email, you can safely ignore it.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
Your Hackatime sign-in link
|
||||
Hackatime — Sign In
|
||||
====================
|
||||
|
||||
Use the link below to sign in to your account:
|
||||
|
||||
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.
|
||||
This link expires in 30 minutes and can only be used once.
|
||||
|
||||
If you didn't request this email, you can ignore it.
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
|
|
|||
|
|
@ -1,6 +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;">
|
||||
<div class="my-4">
|
||||
<a href="<%= url %>" class="inline-block px-4 py-2.5 rounded-md no-underline font-semibold text-sm text-white" style="background-color: #c8394f;">
|
||||
<%= label %>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,55 +1,55 @@
|
|||
<h1 style="font-size: 24px; line-height: 32px; font-weight: 700; color: #0f172a; letter-spacing: -0.02em; margin: 8px 0 0 0;">
|
||||
<h1 class="text-2xl font-bold text-gray-900 tracking-tight mt-2 mb-0">
|
||||
Your coding recap
|
||||
</h1>
|
||||
<p style="font-size: 14px; line-height: 20px; color: #334155; margin: 8px 0 0 0;">
|
||||
<p class="text-sm text-gray-600 mt-2 mb-0">
|
||||
<%= @period_label %>
|
||||
</p>
|
||||
<p style="font-size: 16px; line-height: 24px; color: #334155; margin: 16px 0 0 0;">
|
||||
<p class="text-base text-gray-700 mt-4 mb-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;">
|
||||
<div class="mt-6 rounded-xl overflow-hidden bg-white" style="border: 1px solid #dbe4ee;">
|
||||
<div class="px-5 py-4 bg-gray-50" style="border-bottom: 1px solid #e2e8f0;">
|
||||
<p class="text-xs text-gray-400 m-0">Total coding time</p>
|
||||
<p class="text-3xl font-bold text-gray-900 tracking-tight mt-2 mb-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 class="px-4 py-3 align-top" style="width: 33.33%; border-right: 1px solid #e2e8f0;">
|
||||
<p class="text-xs text-gray-400 m-0">Daily average</p>
|
||||
<p class="text-lg font-semibold text-gray-900 mt-1 mb-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 class="px-4 py-3 align-top" style="width: 33.33%; border-right: 1px solid #e2e8f0;">
|
||||
<p class="text-xs text-gray-400 m-0">Active days</p>
|
||||
<p class="text-lg font-semibold text-gray-900 mt-1 mb-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 class="px-4 py-3 align-top" style="width: 33.33%;">
|
||||
<p class="text-xs text-gray-400 m-0">Heartbeats</p>
|
||||
<p class="text-lg font-semibold text-gray-900 mt-1 mb-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;">
|
||||
<div class="mt-6 p-4 rounded-lg bg-gray-50" style="border: 1px solid #e2e8f0;">
|
||||
<p class="text-sm text-gray-700 m-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>
|
||||
<div class="mt-6 p-4 rounded-lg bg-white" style="border: 1px solid #e2e8f0;">
|
||||
<p class="text-sm font-bold text-gray-900 m-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;' : '' %>">
|
||||
<td class="text-sm text-gray-700 py-2.5" style="<%= 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;' : '' %>">
|
||||
<td align="right" class="text-sm font-semibold text-gray-900 py-2.5" style="<%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
|
||||
<%= project[:duration_label] %>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -57,15 +57,15 @@
|
|||
</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>
|
||||
<div class="mt-4 p-4 rounded-lg bg-white" style="border: 1px solid #e2e8f0;">
|
||||
<p class="text-sm font-bold text-gray-900 m-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;' : '' %>">
|
||||
<td class="text-sm text-gray-700 py-2.5" style="<%= 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;' : '' %>">
|
||||
<td align="right" class="text-sm font-semibold text-gray-900 py-2.5" style="<%= index.positive? ? 'border-top: 1px solid #e2e8f0;' : '' %>">
|
||||
<%= language[:duration_label] %>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,31 @@
|
|||
Your Hackatime weekly summary: <%= @period_label %>
|
||||
Hackatime — Your Coding Recap
|
||||
==============================
|
||||
<%= @period_label %>
|
||||
|
||||
Hi <%= h(@user.display_name) %>,
|
||||
|
||||
Here's your weekly coding snapshot from Hackatime.
|
||||
|
||||
OVERVIEW
|
||||
--------
|
||||
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.
|
||||
|
||||
No coding activity was recorded this week. Start a new session and your next summary will include your project and language breakdown.
|
||||
<% else %>
|
||||
Top projects:
|
||||
<% @top_projects.each do |project| %>
|
||||
- <%= project[:name] %>: <%= project[:duration_label] %>
|
||||
|
||||
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 %>
|
||||
TOP LANGUAGES
|
||||
-------------
|
||||
<% @top_languages.each do |language| -%>
|
||||
• <%= language[:name] %> - <%= language[:duration_label] %>
|
||||
<% end -%>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ module Harbor
|
|||
# Initialize configuration defaults for originally generated Rails version.
|
||||
config.load_defaults 8.0
|
||||
|
||||
if ENV["RAILS_ENV"] == "test"
|
||||
ENV["ENCRYPTION_PRIMARY_KEY"] = "test_primary_key_for_active_record_encryption_123" if ENV["ENCRYPTION_PRIMARY_KEY"].to_s.empty?
|
||||
ENV["ENCRYPTION_DETERMINISTIC_KEY"] = "test_deterministic_key_for_active_record_encrypt_456" if ENV["ENCRYPTION_DETERMINISTIC_KEY"].to_s.empty?
|
||||
ENV["ENCRYPTION_KEY_DERIVATION_SALT"] = "test_key_derivation_salt_789" if ENV["ENCRYPTION_KEY_DERIVATION_SALT"].to_s.empty?
|
||||
end
|
||||
|
||||
config.active_record.encryption.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"]
|
||||
config.active_record.encryption.deterministic_key = ENV["ENCRYPTION_DETERMINISTIC_KEY"]
|
||||
config.active_record.encryption.key_derivation_salt = ENV["ENCRYPTION_KEY_DERIVATION_SALT"]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
if Rails.env.test?
|
||||
ENV["ENCRYPTION_PRIMARY_KEY"] ||= "test_primary_key_for_active_record_encryption_123"
|
||||
ENV["ENCRYPTION_DETERMINISTIC_KEY"] ||= "test_deterministic_key_for_active_record_encrypt_456"
|
||||
ENV["ENCRYPTION_KEY_DERIVATION_SALT"] ||= "test_key_derivation_salt_789"
|
||||
ENV["ENCRYPTION_PRIMARY_KEY"] = "test_primary_key_for_active_record_encryption_123" if ENV["ENCRYPTION_PRIMARY_KEY"].to_s.empty?
|
||||
ENV["ENCRYPTION_DETERMINISTIC_KEY"] = "test_deterministic_key_for_active_record_encrypt_456" if ENV["ENCRYPTION_DETERMINISTIC_KEY"].to_s.empty?
|
||||
ENV["ENCRYPTION_KEY_DERIVATION_SALT"] = "test_key_derivation_salt_789" if ENV["ENCRYPTION_KEY_DERIVATION_SALT"].to_s.empty?
|
||||
end
|
||||
|
||||
Rails.application.config.active_record.encryption.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"]
|
||||
|
|
|
|||
5
config/initializers/premailer.rb
Normal file
5
config/initializers/premailer.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Premailer::Rails.config.merge!(
|
||||
preserve_styles: false,
|
||||
remove_ids: false,
|
||||
remove_comments: true
|
||||
)
|
||||
|
|
@ -1,66 +1,74 @@
|
|||
require "test_helper"
|
||||
|
||||
class WeeklySummaryEmailJobTest < ActiveJob::TestCase
|
||||
DISABLED_REASON = "Weekly summary delivery is intentionally disabled. See WeeklySummaryEmailJob for context.".freeze
|
||||
|
||||
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")
|
||||
skip DISABLED_REASON
|
||||
|
||||
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")
|
||||
# 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")
|
||||
|
||||
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")
|
||||
# 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")
|
||||
|
||||
reference_time = Time.utc(2026, 2, 27, 17, 30, 0) # Friday, 5:30 PM GMT
|
||||
# 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")
|
||||
|
||||
assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
|
||||
WeeklySummaryEmailJob.perform_now(reference_time)
|
||||
end
|
||||
# reference_time = Time.utc(2026, 2, 27, 17, 30, 0) # Friday, 5:30 PM GMT
|
||||
|
||||
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:"
|
||||
# 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")
|
||||
skip DISABLED_REASON
|
||||
|
||||
reference_time = Time.utc(2026, 2, 27, 17, 30, 0)
|
||||
# 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")
|
||||
|
||||
assert_difference -> { ActionMailer::Base.deliveries.count }, 1 do
|
||||
WeeklySummaryEmailJob.perform_now(reference_time)
|
||||
end
|
||||
# reference_time = Time.utc(2026, 2, 27, 17, 30, 0)
|
||||
|
||||
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"
|
||||
# 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")
|
||||
skip DISABLED_REASON
|
||||
|
||||
reference_time = Time.utc(2026, 2, 27, 17, 29, 0)
|
||||
# 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")
|
||||
|
||||
assert_no_difference -> { ActionMailer::Base.deliveries.count } do
|
||||
WeeklySummaryEmailJob.perform_now(reference_time)
|
||||
end
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class HeartbeatExportMailerTest < ActionMailer::TestCase
|
|||
assert_equal 0, mail.attachments.size
|
||||
|
||||
assert_includes mail.html_part.body.decoded, "Your heartbeat export is ready"
|
||||
assert_includes mail.text_part.body.decoded, "Your Hackatime heartbeat export has been generated"
|
||||
assert_includes mail.text_part.body.decoded, "Your heartbeat export (heartbeats_test.zip) is ready to download"
|
||||
assert_includes mail.text_part.body.decoded, @user.display_name
|
||||
assert_includes mail.text_part.body.decoded, "/rails/active_storage/"
|
||||
assert_includes mail.text_part.body.decoded, "heartbeats_test.zip"
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class WeeklySummaryMailerTest < ActionMailer::TestCase
|
|||
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, "TOP LANGUAGES"
|
||||
assert_includes mail.text_part.body.decoded, "hackatime-web"
|
||||
assert_not_includes mail.html_part.body.decoded.downcase, "gradient"
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue