Skip to content

Commit 465a034

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
improve email template further
1 parent d8bcc57 commit 465a034

File tree

3 files changed

+176
-5
lines changed

3 files changed

+176
-5
lines changed

email/templates.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ func (s *Sender) formatNotificationBody(sub *notifier.Subscription, thread *noti
1616
b.WriteString("<meta charset=\"utf-8\">\n")
1717
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
1818
b.WriteString("<style>\n")
19-
b.WriteString("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; }\n")
19+
b.WriteString("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; background: #fff; }\n")
2020
b.WriteString(".post { margin-bottom: 30px; padding-bottom: 30px; border-bottom: 2px solid #e67e22; }\n")
2121
b.WriteString(".post:last-of-type { border-bottom: none; padding-bottom: 0; }\n")
2222
b.WriteString(".post:first-of-type { padding-top: 0; }\n")
@@ -33,6 +33,17 @@ func (s *Sender) formatNotificationBody(sub *notifier.Subscription, thread *noti
3333
b.WriteString(".footer a:first-child { margin-left: 0; }\n")
3434
b.WriteString("a { color: #e67e22; text-decoration: none; }\n")
3535
b.WriteString("a:hover { text-decoration: underline; }\n")
36+
b.WriteString("@media (prefers-color-scheme: dark) {\n")
37+
b.WriteString("body { background: #1a1a1a; color: #e0e0e0; }\n")
38+
b.WriteString(".post-number { color: #a0a0a0; }\n")
39+
b.WriteString(".author { color: #ff8c42; }\n")
40+
b.WriteString(".timestamp { color: #a0a0a0; }\n")
41+
b.WriteString(".content blockquote { border-left-color: #444; color: #b0b0b0; }\n")
42+
b.WriteString(".content img { opacity: 0.9; }\n")
43+
b.WriteString(".footer { border-top-color: #444; color: #a0a0a0; }\n")
44+
b.WriteString(".footer a { color: #a0a0a0; }\n")
45+
b.WriteString("a { color: #ff8c42; }\n")
46+
b.WriteString("}\n")
3647
b.WriteString("</style>\n</head>\n<body>\n")
3748

3849
// Render each post - no redundant header
@@ -63,7 +74,15 @@ func (s *Sender) formatNotificationBody(sub *notifier.Subscription, thread *noti
6374

6475
// Footer with thread link and manage link
6576
b.WriteString("<div class=\"footer\">\n")
66-
b.WriteString(fmt.Sprintf("<a href=\"%s\">View thread</a>\n", escapeHTML(thread.ThreadURL)))
77+
78+
// Link to the last page with anchor to latest post (e.g., .../page-12#post-12345)
79+
// This loads the full page context but scrolls to the most recent post
80+
threadLink := thread.ThreadURL
81+
if len(posts) > 0 && posts[len(posts)-1].URL != "" {
82+
threadLink = posts[len(posts)-1].URL
83+
}
84+
b.WriteString(fmt.Sprintf("<a href=\"%s\">View thread</a>\n", escapeHTML(threadLink)))
85+
6786
manageURL := fmt.Sprintf("%s/manage?token=%s", s.baseURL, url.QueryEscape(sub.Token))
6887
b.WriteString(fmt.Sprintf("<a href=\"%s\">Manage</a>\n", escapeHTML(manageURL)))
6988
b.WriteString("</div>\n")
@@ -77,16 +96,25 @@ func (s *Sender) formatWelcomeBody(sub *notifier.Subscription, thread *notifier.
7796
manageURL := fmt.Sprintf("%s/manage?token=%s", s.baseURL, url.QueryEscape(sub.Token))
7897

7998
var b strings.Builder
80-
b.WriteString("<!DOCTYPE html>\n<html>\n<head>\n")
99+
b.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
81100
b.WriteString("<meta charset=\"utf-8\">\n")
101+
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
82102
b.WriteString("<style>\n")
83-
b.WriteString("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; }\n")
103+
b.WriteString("body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 800px; margin: 0 auto; padding: 20px; background: #fff; }\n")
84104
b.WriteString(".header { border-bottom: 2px solid #e67e22; padding-bottom: 10px; margin-bottom: 20px; }\n")
85105
b.WriteString(".content { background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 15px 0; }\n")
86106
b.WriteString(".info { color: #7f8c8d; font-size: 0.9em; margin: 15px 0; }\n")
87107
b.WriteString(".footer { margin-top: 20px; padding-top: 10px; border-top: 2px solid #ecf0f1; color: #7f8c8d; font-size: 0.9em; }\n")
88108
b.WriteString("a { color: #e67e22; text-decoration: none; }\n")
89109
b.WriteString("a:hover { text-decoration: underline; }\n")
110+
b.WriteString("@media (prefers-color-scheme: dark) {\n")
111+
b.WriteString("body { background: #1a1a1a; color: #e0e0e0; }\n")
112+
b.WriteString(".header { border-bottom-color: #ff8c42; }\n")
113+
b.WriteString(".content { background: #2a2a2a; }\n")
114+
b.WriteString(".info { color: #a0a0a0; }\n")
115+
b.WriteString(".footer { border-top-color: #444; color: #a0a0a0; }\n")
116+
b.WriteString("a { color: #ff8c42; }\n")
117+
b.WriteString("}\n")
90118
b.WriteString("</style>\n</head>\n<body>\n")
91119

92120
b.WriteString("<div class=\"header\">\n")

poll/poll_test.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package poll
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
// TestCalculateInterval verifies the exponential backoff algorithm produces reasonable intervals.
9+
func TestCalculateInterval(t *testing.T) {
10+
now := time.Now()
11+
tests := []struct {
12+
name string
13+
lastPostTime time.Time
14+
wantMin time.Duration
15+
wantMax time.Duration
16+
}{
17+
{
18+
name: "very recent post (5 minutes ago)",
19+
lastPostTime: now.Add(-5 * time.Minute),
20+
wantMin: 5 * time.Minute,
21+
wantMax: 6 * time.Minute,
22+
},
23+
{
24+
name: "recent post (1 hour ago)",
25+
lastPostTime: now.Add(-1 * time.Hour),
26+
wantMin: 6 * time.Minute,
27+
wantMax: 8 * time.Minute,
28+
},
29+
{
30+
name: "active thread (3 hours ago)",
31+
lastPostTime: now.Add(-3 * time.Hour),
32+
wantMin: 9 * time.Minute,
33+
wantMax: 11 * time.Minute,
34+
},
35+
{
36+
name: "moderately active (6 hours ago)",
37+
lastPostTime: now.Add(-6 * time.Hour),
38+
wantMin: 18 * time.Minute,
39+
wantMax: 22 * time.Minute,
40+
},
41+
{
42+
name: "daily active (12 hours ago)",
43+
lastPostTime: now.Add(-12 * time.Hour),
44+
wantMin: 75 * time.Minute,
45+
wantMax: 85 * time.Minute,
46+
},
47+
{
48+
name: "daily thread (24 hours ago)",
49+
lastPostTime: now.Add(-24 * time.Hour),
50+
wantMin: 3*time.Hour + 50*time.Minute,
51+
wantMax: 4 * time.Hour,
52+
},
53+
{
54+
name: "inactive thread (7 days ago)",
55+
lastPostTime: now.Add(-7 * 24 * time.Hour),
56+
wantMin: 4 * time.Hour,
57+
wantMax: 4 * time.Hour,
58+
},
59+
{
60+
name: "zero last polled (error case)",
61+
lastPostTime: now.Add(-1 * time.Hour),
62+
wantMin: 4 * time.Hour,
63+
wantMax: 4 * time.Hour,
64+
},
65+
}
66+
67+
for _, tt := range tests {
68+
t.Run(tt.name, func(t *testing.T) {
69+
lastPolledAt := now
70+
if tt.name == "zero last polled (error case)" {
71+
lastPolledAt = time.Time{}
72+
}
73+
74+
interval, reason := CalculateInterval(tt.lastPostTime, lastPolledAt)
75+
76+
if interval < tt.wantMin || interval > tt.wantMax {
77+
t.Errorf("CalculateInterval() interval = %v, want between %v and %v", interval, tt.wantMin, tt.wantMax)
78+
}
79+
80+
if reason == "" {
81+
t.Error("CalculateInterval() reason should not be empty")
82+
}
83+
84+
t.Logf("Post age: %v → Interval: %v (reason: %s)", time.Since(tt.lastPostTime).Round(time.Minute), interval.Round(time.Minute), reason)
85+
})
86+
}
87+
}
88+
89+
// TestCalculateIntervalNeverReturnsZero ensures we never return a zero interval.
90+
func TestCalculateIntervalNeverReturnsZero(t *testing.T) {
91+
now := time.Now()
92+
93+
// Test various edge cases
94+
testCases := []struct {
95+
name string
96+
lastPostTime time.Time
97+
lastPolledAt time.Time
98+
}{
99+
{"current time", now, now},
100+
{"1 second ago", now.Add(-1 * time.Second), now},
101+
{"zero post time", time.Time{}, now},
102+
{"zero polled time", now, time.Time{}},
103+
{"both zero", time.Time{}, time.Time{}},
104+
{"far future (should never happen)", now.Add(24 * time.Hour), now},
105+
}
106+
107+
for _, tc := range testCases {
108+
t.Run(tc.name, func(t *testing.T) {
109+
interval, _ := CalculateInterval(tc.lastPostTime, tc.lastPolledAt)
110+
if interval == 0 {
111+
t.Errorf("CalculateInterval() returned 0 for %s", tc.name)
112+
}
113+
if interval < 5*time.Minute {
114+
t.Errorf("CalculateInterval() returned %v, which is less than minimum (5min) for %s", interval, tc.name)
115+
}
116+
})
117+
}
118+
}
119+
120+
// TestCalculateIntervalExponentialBehavior verifies the interval grows exponentially.
121+
func TestCalculateIntervalExponentialBehavior(t *testing.T) {
122+
now := time.Now()
123+
124+
// Test that interval approximately doubles every 3 hours
125+
interval3h, _ := CalculateInterval(now.Add(-3*time.Hour), now)
126+
interval6h, _ := CalculateInterval(now.Add(-6*time.Hour), now)
127+
128+
ratio := float64(interval6h) / float64(interval3h)
129+
130+
// Should be approximately 2x (allow 10% tolerance for rounding)
131+
if ratio < 1.8 || ratio > 2.2 {
132+
t.Errorf("Expected interval to double every 3 hours, got ratio %.2f (3h=%v, 6h=%v)", ratio, interval3h, interval6h)
133+
}
134+
135+
t.Logf("3h interval: %v, 6h interval: %v, ratio: %.2fx", interval3h, interval6h, ratio)
136+
}

scraper/scraper.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,13 +347,20 @@ func parsePage(body interface{ Read([]byte) (int, error) }, threadURL string) (*
347347
htmlContent = content // Fallback to plain text
348348
}
349349

350+
// Build proper URL with page number (threadURL here is actually the pageURL from fetchSinglePage)
351+
// Format: https://advrider.com/f/threads/example.123/page-12#post-456
352+
postURL := threadURL
353+
// Ensure URL doesn't have trailing slash before adding anchor
354+
postURL = strings.TrimSuffix(postURL, "/")
355+
postURL = postURL + "#post-" + id
356+
350357
posts = append(posts, &notifier.Post{
351358
ID: id,
352359
Author: author,
353360
Content: content,
354361
HTMLContent: htmlContent,
355362
Timestamp: timestamp,
356-
URL: threadURL + "#post-" + id,
363+
URL: postURL,
357364
})
358365
})
359366

0 commit comments

Comments
 (0)