Skip to content

Commit a5b568c

Browse files
bscottCopilotCopilot
authored
feat: Renewal reminder emails and improved subscription notes display (v0.4.8) (#58)
* Automatically set version from git tags at build time - Update version.go to prefer semantic version tag over git commit - Update Makefile to extract latest git tag and inject via ldflags - Update Dockerfile to accept GIT_TAG and GIT_COMMIT build args - Update GitHub Actions workflow to extract and pass version info to Docker build This ensures the version shown in the settings panel is always up to date with the latest git tag when building for release. * feat: Implement renewal reminder emails and improve subscription notes display - Add renewal reminder email functionality with daily scheduler - Add SendRenewalReminder method to EmailService - Add GetSubscriptionsNeedingReminders to SubscriptionService - Add background scheduler that checks for upcoming renewals daily - Change subscription notes display from separate row to hover tooltip - Add eye icon with tooltip for viewing notes on subscriptions page - Add comprehensive tests for renewal reminder functionality - Tooltip auto-sizes to match note text width * docs: Add release notes for v0.4.8 * fix: Remove .git directory copy from Dockerfile and pass version as build args - Remove COPY .git/ from Dockerfile (not needed when build args are provided) - Update test-build workflow to extract and pass GIT_TAG and GIT_COMMIT as build args - Fixes Docker build error in GitHub Actions where .git directory is not available - More efficient build process without copying entire git history * fix: Improve accessibility for subscription notes tooltip - Add aria-label to button for screen readers - Add aria-describedby to link button with tooltip - Add id to tooltip div for aria reference - Add group-focus-within classes for keyboard accessibility - Tooltip now accessible to keyboard-only and screen reader users * fix: Add panic recovery to renewal reminder scheduler - Add defer/recover around checkAndSendRenewalReminders call - Prevents scheduler from crashing if reminder check panics - Ensures ticker continues running even if individual checks fail - Improves robustness of background scheduler * fix: Prevent duplicate renewal reminders by tracking sent reminders - Add LastReminderSent and LastReminderRenewalDate fields to Subscription model - Add migration to add reminder tracking fields to database - Filter out subscriptions that already have reminders sent for current renewal date - Update subscription when reminder is successfully sent - Prevents sending multiple reminders for the same renewal period - If renewal date changes, a new reminder will be sent * test: Add test for duplicate reminder prevention * style: Fix import ordering and formatting * feat: Add configurable high cost threshold with currency support - Add float setting methods to SettingsService - Update IsHighCost() to accept threshold parameter - Add currency-aware high cost checking that converts subscription costs to display currency - Add threshold input field in settings UI with currency symbol display - Update subscription handlers to use currency-aware threshold comparison - Update tests to use new threshold parameter * chore: Remove RELEASE_NOTES files from git tracking - Remove RELEASE_NOTES_v0.4.7.md and RELEASE_NOTES_v0.4.8.md from git - Add RELEASE_NOTES*.md to .gitignore to prevent future commits - Files remain locally but are no longer tracked * refactor: Address code quality improvements - Improve days calculation precision using time.Until() instead of manual calculation - Add defensive ticker cleanup with defer ticker.Stop() - Add documentation comment explaining ticker lifecycle - Improve currency conversion fallback error logging with more context - Remove unused variable in GetSubscriptionsNeedingReminders * Initial plan * Update internal/models/subscription.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: Use configured currency symbol in email templates instead of hard-coded dollar sign Co-authored-by: bscott <191290+bscott@users.noreply.github.com> * docs: Update progress - all tasks completed Co-authored-by: bscott <191290+bscott@users.noreply.github.com> * Update templates/subscriptions.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update internal/service/email.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update internal/service/email.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent cca25da commit a5b568c

File tree

23 files changed

+968
-93
lines changed

23 files changed

+968
-93
lines changed

.github/workflows/docker-publish.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ jobs:
4646
type=semver,pattern={{version}}
4747
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }}
4848
49+
- name: Extract version info
50+
id: version
51+
run: |
52+
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
53+
GIT_TAG="${{ github.ref_name }}"
54+
else
55+
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")
56+
fi
57+
GIT_COMMIT=$(git rev-parse --short HEAD)
58+
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
59+
echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
60+
4961
- name: Build and push Docker image
5062
uses: docker/build-push-action@v5
5163
with:
@@ -57,4 +69,6 @@ jobs:
5769
cache-from: type=gha
5870
cache-to: type=gha,mode=max
5971
build-args: |
60-
BUILDKIT_INLINE_CACHE=1
72+
BUILDKIT_INLINE_CACHE=1
73+
GIT_TAG=${{ steps.version.outputs.tag }}
74+
GIT_COMMIT=${{ steps.version.outputs.commit }}

.github/workflows/test-build.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ jobs:
5353
- name: Run tests
5454
run: go test -v ./...
5555

56+
- name: Extract version info
57+
id: version
58+
run: |
59+
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
60+
GIT_TAG="${{ github.ref_name }}"
61+
else
62+
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "dev")
63+
fi
64+
GIT_COMMIT=$(git rev-parse --short HEAD)
65+
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
66+
echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
67+
5668
- name: Set up Docker Buildx
5769
uses: docker/setup-buildx-action@v3
5870

@@ -63,4 +75,8 @@ jobs:
6375
platforms: linux/amd64
6476
push: false
6577
cache-from: type=gha
66-
cache-to: type=gha,mode=max
78+
cache-to: type=gha,mode=max
79+
build-args: |
80+
BUILDKIT_INLINE_CACHE=1
81+
GIT_TAG=${{ steps.version.outputs.tag }}
82+
GIT_COMMIT=${{ steps.version.outputs.commit }}

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,7 @@ server.log
6868
server
6969
*.db
7070
data/
71-
subtrackr
71+
subtrackr
72+
73+
# Release notes (draft files, not committed)
74+
RELEASE_NOTES*.md

Dockerfile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ RUN go mod download && go mod verify
1818
COPY cmd/ ./cmd/
1919
COPY internal/ ./internal/
2020

21-
# Build the application with optimizations
21+
# Build arguments for version info (should be provided by CI/CD)
22+
ARG GIT_TAG=dev
23+
ARG GIT_COMMIT=unknown
24+
25+
# Build the application with optimizations and version info
26+
# Use build args directly - no need for .git directory
2227
RUN CGO_ENABLED=1 GOOS=linux go build \
23-
-ldflags="-w -s" \
28+
-ldflags="-w -s -X 'subtrackr/internal/version.Version=${GIT_TAG}' -X 'subtrackr/internal/version.GitCommit=${GIT_COMMIT}'" \
2429
-o subtrackr ./cmd/server
2530

2631
# Final stage

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Variables
22
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
3+
GIT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null || echo "dev")
34
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
4-
LDFLAGS := -X 'subtrackr/internal/version.GitCommit=$(GIT_COMMIT)'
5+
LDFLAGS := -X 'subtrackr/internal/version.GitCommit=$(GIT_COMMIT)' -X 'subtrackr/internal/version.Version=$(GIT_TAG)'
56

67
# Default target
78
.PHONY: all

cmd/server/main.go

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ func main() {
135135
// seedSampleData(subscriptionService)
136136
// }
137137

138+
// Start renewal reminder scheduler
139+
go startRenewalReminderScheduler(subscriptionService, emailService, settingsService)
138140

139141
// Start server
140142
port := os.Getenv("PORT")
@@ -149,7 +151,7 @@ func main() {
149151
// loadTemplates loads HTML templates with better error handling for arm64 compatibility
150152
func loadTemplates() *template.Template {
151153
tmpl := template.New("")
152-
154+
153155
// Add template functions
154156
tmpl.Funcs(template.FuncMap{
155157
"add": func(a, b float64) float64 { return a + b },
@@ -177,14 +179,14 @@ func loadTemplates() *template.Template {
177179
}
178180
},
179181
})
180-
182+
181183
// Critical templates required for basic functionality
182184
criticalTemplates := []string{
183185
"templates/dashboard.html",
184186
"templates/subscriptions.html",
185187
"templates/error.html",
186188
}
187-
189+
188190
// All template files to load
189191
templateFiles := []string{
190192
"templates/dashboard.html",
@@ -200,11 +202,11 @@ func loadTemplates() *template.Template {
200202
"templates/form-errors.html",
201203
"templates/error.html",
202204
}
203-
205+
204206
var parsedCount int
205207
var failedCount int
206208
var missingCritical []string
207-
209+
208210
// Load templates individually to catch arm64-specific issues
209211
for _, file := range templateFiles {
210212
if _, err := os.Stat(file); err != nil {
@@ -217,7 +219,7 @@ func loadTemplates() *template.Template {
217219
}
218220
continue
219221
}
220-
222+
221223
if _, err := tmpl.ParseFiles(file); err != nil {
222224
log.Printf("Error: Failed to parse template %s: %v", file, err)
223225
failedCount++
@@ -231,20 +233,20 @@ func loadTemplates() *template.Template {
231233
parsedCount++
232234
}
233235
}
234-
236+
235237
// Log template loading summary
236238
log.Printf("Template loading summary: %d parsed, %d failed, %d total", parsedCount, failedCount, len(templateFiles))
237-
239+
238240
// Fatal error if critical templates are missing
239241
if len(missingCritical) > 0 {
240242
log.Fatalf("Critical templates failed to load: %v. Application cannot continue.", missingCritical)
241243
}
242-
244+
243245
// Warn if too many templates failed
244246
if failedCount > len(templateFiles)/2 {
245247
log.Printf("Warning: More than half of templates failed to load (%d/%d). Application may not function correctly.", failedCount, len(templateFiles))
246248
}
247-
249+
248250
return tmpl
249251
}
250252

@@ -323,3 +325,91 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
323325
v1.GET("/export/json", handler.ExportJSON)
324326
}
325327
}
328+
329+
// startRenewalReminderScheduler starts a background goroutine that checks for
330+
// upcoming renewals and sends reminder emails daily
331+
func startRenewalReminderScheduler(subscriptionService *service.SubscriptionService, emailService *service.EmailService, settingsService *service.SettingsService) {
332+
// Run immediately on startup (after a short delay to let server initialize)
333+
go func() {
334+
time.Sleep(30 * time.Second) // Wait 30 seconds for server to fully start
335+
checkAndSendRenewalReminders(subscriptionService, emailService, settingsService)
336+
}()
337+
338+
// Then run daily at midnight
339+
// Note: Ticker is intentionally not stopped as this is a long-running server process.
340+
// The ticker will run for the lifetime of the application, which is the desired behavior.
341+
ticker := time.NewTicker(24 * time.Hour)
342+
go func() {
343+
defer ticker.Stop() // Clean up ticker if goroutine exits (defensive programming)
344+
for range ticker.C {
345+
// Recover from any panics in the reminder check to keep the scheduler running
346+
func() {
347+
defer func() {
348+
if r := recover(); r != nil {
349+
log.Printf("Panic in renewal reminder check: %v", r)
350+
}
351+
}()
352+
checkAndSendRenewalReminders(subscriptionService, emailService, settingsService)
353+
}()
354+
}
355+
}()
356+
}
357+
358+
// checkAndSendRenewalReminders checks for subscriptions needing reminders and sends emails
359+
func checkAndSendRenewalReminders(subscriptionService *service.SubscriptionService, emailService *service.EmailService, settingsService *service.SettingsService) {
360+
// Check if renewal reminders are enabled
361+
enabled, err := settingsService.GetBoolSetting("renewal_reminders", false)
362+
if err != nil || !enabled {
363+
return // Silently skip if disabled or error
364+
}
365+
366+
// Get reminder days setting
367+
reminderDays := settingsService.GetIntSettingWithDefault("reminder_days", 7)
368+
if reminderDays <= 0 {
369+
return // No reminders if days is 0 or negative
370+
}
371+
372+
// Get subscriptions needing reminders
373+
subscriptions, err := subscriptionService.GetSubscriptionsNeedingReminders(reminderDays)
374+
if err != nil {
375+
log.Printf("Error getting subscriptions for renewal reminders: %v", err)
376+
return
377+
}
378+
379+
if len(subscriptions) == 0 {
380+
log.Printf("No subscriptions need renewal reminders today")
381+
return
382+
}
383+
384+
log.Printf("Checking %d subscription(s) for renewal reminders", len(subscriptions))
385+
386+
// Send reminder for each subscription
387+
sentCount := 0
388+
failedCount := 0
389+
for sub, daysUntil := range subscriptions {
390+
err := emailService.SendRenewalReminder(sub, daysUntil)
391+
if err != nil {
392+
log.Printf("Error sending renewal reminder for subscription %s (ID: %d): %v", sub.Name, sub.ID, err)
393+
failedCount++
394+
} else {
395+
// Mark reminder as sent for this renewal date
396+
now := time.Now()
397+
sub.LastReminderSent = &now
398+
if sub.RenewalDate != nil {
399+
renewalDateCopy := *sub.RenewalDate
400+
sub.LastReminderRenewalDate = &renewalDateCopy
401+
}
402+
403+
// Update the subscription in the database
404+
_, updateErr := subscriptionService.Update(sub.ID, sub)
405+
if updateErr != nil {
406+
log.Printf("Warning: Failed to update last reminder sent for subscription %s (ID: %d): %v", sub.Name, sub.ID, updateErr)
407+
}
408+
409+
log.Printf("Sent renewal reminder for subscription %s (renews in %d days)", sub.Name, daysUntil)
410+
sentCount++
411+
}
412+
}
413+
414+
log.Printf("Renewal reminder check complete: %d sent, %d failed", sentCount, failedCount)
415+
}

go.mod

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module subtrackr
33
go 1.21
44

55
require (
6+
github.com/dromara/carbon/v2 v2.6.11
67
github.com/gin-gonic/gin v1.9.1
78
github.com/stretchr/testify v1.11.1
89
gorm.io/driver/sqlite v1.5.4
@@ -13,7 +14,6 @@ require (
1314
github.com/bytedance/sonic v1.9.1 // indirect
1415
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
1516
github.com/davecgh/go-spew v1.1.1 // indirect
16-
github.com/dromara/carbon/v2 v2.6.11 // indirect
1717
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
1818
github.com/gin-contrib/sse v0.1.0 // indirect
1919
github.com/go-playground/locales v0.14.1 // indirect
@@ -31,7 +31,6 @@ require (
3131
github.com/modern-go/reflect2 v1.0.2 // indirect
3232
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
3333
github.com/pmezard/go-difflib v1.0.0 // indirect
34-
github.com/stretchr/objx v0.5.2 // indirect
3534
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
3635
github.com/ugorji/go/codec v1.2.11 // indirect
3736
golang.org/x/arch v0.3.0 // indirect

go.sum

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
5656
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
5757
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
5858
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
59-
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
60-
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
6159
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
6260
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
6361
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

internal/database/migrations.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func RunMigrations(db *gorm.DB) error {
2121
migrateCurrencyFields,
2222
migrateDateCalculationVersioning,
2323
migrateSubscriptionIcons,
24+
migrateReminderTracking,
2425
}
2526

2627
for _, migration := range migrations {
@@ -180,4 +181,31 @@ func migrateSubscriptionIcons(db *gorm.DB) error {
180181

181182
log.Println("Migration completed: Subscription icon URLs added")
182183
return nil
184+
}
185+
186+
// migrateReminderTracking adds fields to track when reminders were sent
187+
func migrateReminderTracking(db *gorm.DB) error {
188+
// Check if last_reminder_sent column already exists
189+
var count int64
190+
db.Raw("SELECT COUNT(*) FROM pragma_table_info('subscriptions') WHERE name='last_reminder_sent'").Scan(&count)
191+
192+
if count > 0 {
193+
// Migration already completed
194+
return nil
195+
}
196+
197+
log.Println("Running migration: Adding reminder tracking fields...")
198+
199+
// Add last_reminder_sent column
200+
if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN last_reminder_sent DATETIME").Error; err != nil {
201+
log.Printf("Note: Could not add last_reminder_sent column: %v", err)
202+
}
203+
204+
// Add last_reminder_renewal_date column
205+
if err := db.Exec("ALTER TABLE subscriptions ADD COLUMN last_reminder_renewal_date DATETIME").Error; err != nil {
206+
log.Printf("Note: Could not add last_reminder_renewal_date column: %v", err)
207+
}
208+
209+
log.Println("Migration completed: Reminder tracking fields added")
210+
return nil
183211
}

internal/handlers/settings.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,19 @@ func (h *SettingsHandler) UpdateNotificationSetting(c *gin.Context) {
207207
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid days value"})
208208
}
209209

210+
case "threshold":
211+
thresholdStr := c.PostForm("high_cost_threshold")
212+
if threshold, err := strconv.ParseFloat(thresholdStr, 64); err == nil && threshold >= 0 && threshold <= 10000 {
213+
err := h.service.SetFloatSetting("high_cost_threshold", threshold)
214+
if err != nil {
215+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
216+
return
217+
}
218+
c.JSON(http.StatusOK, gin.H{"threshold": threshold})
219+
} else {
220+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid threshold value (must be between 0 and 10000)"})
221+
}
222+
210223
default:
211224
c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown setting"})
212225
}
@@ -215,9 +228,10 @@ func (h *SettingsHandler) UpdateNotificationSetting(c *gin.Context) {
215228
// GetNotificationSettings returns current notification settings
216229
func (h *SettingsHandler) GetNotificationSettings(c *gin.Context) {
217230
settings := models.NotificationSettings{
218-
RenewalReminders: h.service.GetBoolSettingWithDefault("renewal_reminders", false),
219-
HighCostAlerts: h.service.GetBoolSettingWithDefault("high_cost_alerts", true),
220-
ReminderDays: h.service.GetIntSettingWithDefault("reminder_days", 7),
231+
RenewalReminders: h.service.GetBoolSettingWithDefault("renewal_reminders", false),
232+
HighCostAlerts: h.service.GetBoolSettingWithDefault("high_cost_alerts", true),
233+
HighCostThreshold: h.service.GetFloatSettingWithDefault("high_cost_threshold", 50.0),
234+
ReminderDays: h.service.GetIntSettingWithDefault("reminder_days", 7),
221235
}
222236

223237
c.JSON(http.StatusOK, settings)

0 commit comments

Comments
 (0)