feat: Renewal reminder emails and improved subscription notes display (v0.4.8) #58

Merged
bscott merged 20 commits from v0.4.8 into main 2025-12-09 00:33:30 -05:00
bscott commented 2025-11-15 15:54:22 -05:00 (Migrated from github.com)

🎉 New Features

Renewal Reminder Emails

  • Automatic renewal reminders via email
  • Configurable reminder window (default: 7 days)
  • Daily background scheduler checks for upcoming renewals
  • Beautiful HTML email template with subscription details

Improved Subscription Notes Display

  • Notes now shown in hover tooltip instead of separate row
  • Eye icon indicator when notes exist
  • Auto-sizing tooltip that matches text width
  • Cleaner table layout

🔧 Technical Changes

  • Added SendRenewalReminder() method to EmailService
  • Added GetSubscriptionsNeedingReminders() method to SubscriptionService
  • Implemented background scheduler in main.go
  • Added comprehensive test suite (13 test cases)
  • Updated subscription templates with tooltip UI

🧪 Testing

  • All tests passing
  • Comprehensive coverage of edge cases and boundary conditions

📝 Files Changed

  • cmd/server/main.go - Scheduler implementation
  • internal/service/email.go - Email reminder method
  • internal/service/subscription.go - Reminder query method
  • internal/service/renewal_reminder_test.go - Test suite
  • templates/subscriptions.html - Tooltip UI
  • templates/subscription-list.html - Tooltip UI

See RELEASE_NOTES_v0.4.8.md for full details.

## 🎉 New Features ### Renewal Reminder Emails - Automatic renewal reminders via email - Configurable reminder window (default: 7 days) - Daily background scheduler checks for upcoming renewals - Beautiful HTML email template with subscription details ### Improved Subscription Notes Display - Notes now shown in hover tooltip instead of separate row - Eye icon indicator when notes exist - Auto-sizing tooltip that matches text width - Cleaner table layout ## 🔧 Technical Changes - Added `SendRenewalReminder()` method to EmailService - Added `GetSubscriptionsNeedingReminders()` method to SubscriptionService - Implemented background scheduler in main.go - Added comprehensive test suite (13 test cases) - Updated subscription templates with tooltip UI ## 🧪 Testing - All tests passing - Comprehensive coverage of edge cases and boundary conditions ## 📝 Files Changed - `cmd/server/main.go` - Scheduler implementation - `internal/service/email.go` - Email reminder method - `internal/service/subscription.go` - Reminder query method - `internal/service/renewal_reminder_test.go` - Test suite - `templates/subscriptions.html` - Tooltip UI - `templates/subscription-list.html` - Tooltip UI See RELEASE_NOTES_v0.4.8.md for full details.
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2025-11-15 16:16:55 -05:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull Request Overview

This PR adds automatic renewal reminder emails and improves the subscription notes display UI. The renewal reminder feature includes a background scheduler that checks daily for upcoming renewals and sends email notifications. The notes display has been enhanced with hover tooltips instead of separate table rows for a cleaner interface.

Key changes:

  • Automatic email reminders for upcoming subscription renewals with configurable reminder windows
  • Background scheduler for daily renewal checks
  • Improved subscription notes display using hover tooltips with eye icon indicators

Reviewed Changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
cmd/server/main.go Implements background scheduler for daily renewal reminder checks
internal/service/email.go Adds SendRenewalReminder method with HTML email template
internal/service/subscription.go Adds GetSubscriptionsNeedingReminders query method
internal/service/renewal_reminder_test.go Comprehensive test suite with 13 test cases
templates/subscriptions.html Replaces notes row with hover tooltip UI
templates/subscription-list.html Replaces notes row with hover tooltip UI
internal/version/version.go Improves version precedence logic
Makefile Adds GIT_TAG to build flags
Dockerfile Adds build args for version info
.github/workflows/test-build.yml Extracts and passes version info to Docker build
.github/workflows/docker-publish.yml Extracts and passes version info to Docker build
RELEASE_NOTES_v0.4.8.md Release documentation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull Request Overview This PR adds automatic renewal reminder emails and improves the subscription notes display UI. The renewal reminder feature includes a background scheduler that checks daily for upcoming renewals and sends email notifications. The notes display has been enhanced with hover tooltips instead of separate table rows for a cleaner interface. Key changes: - Automatic email reminders for upcoming subscription renewals with configurable reminder windows - Background scheduler for daily renewal checks - Improved subscription notes display using hover tooltips with eye icon indicators ### Reviewed Changes Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments. <details> <summary>Show a summary per file</summary> | File | Description | | ---- | ----------- | | cmd/server/main.go | Implements background scheduler for daily renewal reminder checks | | internal/service/email.go | Adds SendRenewalReminder method with HTML email template | | internal/service/subscription.go | Adds GetSubscriptionsNeedingReminders query method | | internal/service/renewal_reminder_test.go | Comprehensive test suite with 13 test cases | | templates/subscriptions.html | Replaces notes row with hover tooltip UI | | templates/subscription-list.html | Replaces notes row with hover tooltip UI | | internal/version/version.go | Improves version precedence logic | | Makefile | Adds GIT_TAG to build flags | | Dockerfile | Adds build args for version info | | .github/workflows/test-build.yml | Extracts and passes version info to Docker build | | .github/workflows/docker-publish.yml | Extracts and passes version info to Docker build | | RELEASE_NOTES_v0.4.8.md | Release documentation | </details> --- 💡 <a href="/bscott/subtrackr/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:16:54 -05:00

The ticker is never stopped, causing a resource leak. The ticker should be stored and stopped when the application shuts down (e.g., using context cancellation or graceful shutdown). Additionally, the scheduler runs every 24 hours from startup, not at midnight as mentioned in the comment on line 338.

The ticker is never stopped, causing a resource leak. The ticker should be stored and stopped when the application shuts down (e.g., using context cancellation or graceful shutdown). Additionally, the scheduler runs every 24 hours from startup, not at midnight as mentioned in the comment on line 338.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:16:54 -05:00

Duplicate reminders will be sent for the same subscription on consecutive days if it remains within the reminder window. For example, a subscription renewing in 5 days will receive a reminder today, tomorrow (4 days), the next day (3 days), etc. Consider tracking when the last reminder was sent to avoid sending duplicate reminders for the same renewal period.

Duplicate reminders will be sent for the same subscription on consecutive days if it remains within the reminder window. For example, a subscription renewing in 5 days will receive a reminder today, tomorrow (4 days), the next day (3 days), etc. Consider tracking when the last reminder was sent to avoid sending duplicate reminders for the same renewal period.
@ -161,0 +173,4 @@
<div id="note-tooltip-{{.ID}}" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200 z-10 whitespace-nowrap">
<p>{{.Notes}}</p>
<div class="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
</div>
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:16:55 -05:00

The button lacks an accessible label for screen readers. The tooltip on hover is not accessible to keyboard-only users or screen reader users. Add an aria-label attribute to the button and consider making the tooltip keyboard-accessible using aria-describedby or implementing keyboard focus behavior.

                                aria-label="View note"
                                aria-describedby="note-tooltip-{{.ID}}"
                                title="View note">
                                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
                                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
                                </svg>
                            </button>
                            <div id="note-tooltip-{{.ID}}" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200 z-10 whitespace-nowrap">
                                <p>{{.Notes}}</p>
                                <div class="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
The button lacks an accessible label for screen readers. The tooltip on hover is not accessible to keyboard-only users or screen reader users. Add an aria-label attribute to the button and consider making the tooltip keyboard-accessible using aria-describedby or implementing keyboard focus behavior. ```suggestion aria-label="View note" aria-describedby="note-tooltip-{{.ID}}" title="View note"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path> </svg> </button> <div id="note-tooltip-{{.ID}}" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200 z-10 whitespace-nowrap"> <p>{{.Notes}}</p> <div class="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div> ```
@ -341,38 +341,49 @@
</td>
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:16:54 -05:00

The button lacks an accessible label for screen readers. The tooltip on hover is not accessible to keyboard-only users or screen reader users. Add an aria-label attribute to the button and consider making the tooltip keyboard-accessible using aria-describedby or implementing keyboard focus behavior.

                                    title="View note"
                                    aria-label="View note"
                                    aria-describedby="note-tooltip-{{.ID}}">
                                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
                                    </svg>
                                </button>
                                <div id="note-tooltip-{{.ID}}" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus:opacity-100 group-focus:visible transition-all duration-200 z-10 whitespace-nowrap">
The button lacks an accessible label for screen readers. The tooltip on hover is not accessible to keyboard-only users or screen reader users. Add an aria-label attribute to the button and consider making the tooltip keyboard-accessible using aria-describedby or implementing keyboard focus behavior. ```suggestion title="View note" aria-label="View note" aria-describedby="note-tooltip-{{.ID}}"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path> </svg> </button> <div id="note-tooltip-{{.ID}}" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus:opacity-100 group-focus:visible transition-all duration-200 z-10 whitespace-nowrap"> ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2025-11-15 16:29:36 -05:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull Request Overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull Request Overview Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments. --- 💡 <a href="/bscott/subtrackr/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
@ -323,3 +325,91 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
v1.GET("/export/json", handler.ExportJSON)
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:29:34 -05:00

The scheduler will send duplicate reminders every day for the same subscription if it remains within the reminder window. For example, a subscription renewing in 5 days will receive a reminder today, tomorrow (4 days), the next day (3 days), etc.

Consider adding a tracking mechanism (e.g., last_reminder_sent_at field on the Subscription model) to ensure each subscription only receives one reminder per renewal cycle. Alternatively, only send reminders when daysUntil equals a specific value (e.g., exactly 7 days, 3 days, and 1 day).

The scheduler will send duplicate reminders every day for the same subscription if it remains within the reminder window. For example, a subscription renewing in 5 days will receive a reminder today, tomorrow (4 days), the next day (3 days), etc. Consider adding a tracking mechanism (e.g., `last_reminder_sent_at` field on the Subscription model) to ensure each subscription only receives one reminder per renewal cycle. Alternatively, only send reminders when `daysUntil` equals a specific value (e.g., exactly 7 days, 3 days, and 1 day).
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:29:34 -05:00

The scheduler doesn't run at midnight as the comment states. It runs 24 hours after the application starts. If the app starts at 2 PM, reminders will be sent at 2 PM daily, not at midnight.

To run at midnight, consider using a proper cron-like scheduler or calculating the time until the next midnight:

now := time.Now()
nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
time.Sleep(time.Until(nextMidnight))
// then use ticker for subsequent days
	go func() {
		// Calculate duration until next midnight
		now := time.Now()
		nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
		time.Sleep(time.Until(nextMidnight))
		for {
			checkAndSendRenewalReminders(subscriptionService, emailService, settingsService)
			time.Sleep(24 * time.Hour)
The scheduler doesn't run at midnight as the comment states. It runs 24 hours after the application starts. If the app starts at 2 PM, reminders will be sent at 2 PM daily, not at midnight. To run at midnight, consider using a proper cron-like scheduler or calculating the time until the next midnight: ```go now := time.Now() nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) time.Sleep(time.Until(nextMidnight)) // then use ticker for subsequent days ``` ```suggestion go func() { // Calculate duration until next midnight now := time.Now() nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) time.Sleep(time.Until(nextMidnight)) for { checkAndSendRenewalReminders(subscriptionService, emailService, settingsService) time.Sleep(24 * time.Hour) ```
@ -212,0 +220,4 @@
data := AlertData{
Subscription: subscription,
CurrencySymbol: currencySymbol,
}
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:29:35 -05:00

The email template hardcodes the $ currency symbol, which doesn't respect the user's configured currency preference. This is inconsistent with the rest of the application which uses the currency setting from SettingsService.GetCurrencySymbol().

Consider passing the currency symbol to the email template:

type ReminderData struct {
    *models.Subscription
    DaysUntilRenewal int
    CurrencySymbol   string
}
data := ReminderData{
    Subscription:      subscription,
    DaysUntilRenewal: daysUntilRenewal,
    CurrencySymbol:   e.settingsService.GetCurrencySymbol(),
}

Then use {{.CurrencySymbol}} in the template instead of $.

The email template hardcodes the `$` currency symbol, which doesn't respect the user's configured currency preference. This is inconsistent with the rest of the application which uses the currency setting from `SettingsService.GetCurrencySymbol()`. Consider passing the currency symbol to the email template: ```go type ReminderData struct { *models.Subscription DaysUntilRenewal int CurrencySymbol string } data := ReminderData{ Subscription: subscription, DaysUntilRenewal: daysUntilRenewal, CurrencySymbol: e.settingsService.GetCurrencySymbol(), } ``` Then use `{{.CurrencySymbol}}` in the template instead of `$`.
@ -93,3 +94,46 @@ func (s *SubscriptionService) GetStats() (*models.Stats, error) {
func (s *SubscriptionService) GetAllCategories() ([]models.Category, error) {
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:29:35 -05:00

The calculation int(sub.RenewalDate.Sub(now).Hours() / 24) truncates hours to days, which could lead to incorrect day counts. For example, if a renewal is in 23 hours (0.95 days), it will be counted as 0 days, but if it's in 25 hours (1.04 days), it will be counted as 1 day.

Consider using time.Until() with proper rounding:

daysUntil := int(math.Round(time.Until(*sub.RenewalDate).Hours() / 24))

Or for more precise date-based calculation:

now := time.Now().Truncate(24 * time.Hour)
renewal := sub.RenewalDate.Truncate(24 * time.Hour)
daysUntil := int(renewal.Sub(now).Hours() / 24)
The calculation `int(sub.RenewalDate.Sub(now).Hours() / 24)` truncates hours to days, which could lead to incorrect day counts. For example, if a renewal is in 23 hours (0.95 days), it will be counted as 0 days, but if it's in 25 hours (1.04 days), it will be counted as 1 day. Consider using `time.Until()` with proper rounding: ```go daysUntil := int(math.Round(time.Until(*sub.RenewalDate).Hours() / 24)) ``` Or for more precise date-based calculation: ```go now := time.Now().Truncate(24 * time.Hour) renewal := sub.RenewalDate.Truncate(24 * time.Hour) daysUntil := int(renewal.Sub(now).Hours() / 24) ```
@ -161,0 +164,4 @@
class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150"
aria-label="View note"
aria-describedby="note-tooltip-{{.ID}}"
title="View note">
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:29:35 -05:00

The button has aria-describedby="note-tooltip-{{.ID}}" pointing to the tooltip div, but this is semantically incorrect. The aria-describedby attribute should reference descriptive text, not interactive content. Additionally, the button doesn't actually do anything when clicked - it only works on hover.

Consider either:

  1. Making this a non-interactive element (e.g., <span> with role="img") if it's purely visual
  2. Adding click functionality to toggle the tooltip for better mobile/touch support
  3. Using aria-label alone without aria-describedby if the tooltip is purely supplementary
The button has `aria-describedby="note-tooltip-{{.ID}}"` pointing to the tooltip div, but this is semantically incorrect. The `aria-describedby` attribute should reference descriptive text, not interactive content. Additionally, the button doesn't actually do anything when clicked - it only works on hover. Consider either: 1. Making this a non-interactive element (e.g., `<span>` with `role="img"`) if it's purely visual 2. Adding click functionality to toggle the tooltip for better mobile/touch support 3. Using `aria-label` alone without `aria-describedby` if the tooltip is purely supplementary
@ -341,38 +341,49 @@
</td>
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:29:35 -05:00

The tooltip uses whitespace-nowrap which prevents text wrapping and could cause very long notes to overflow off-screen or be cut off. This creates a poor user experience for subscriptions with lengthy notes.

Consider either:

  1. Removing whitespace-nowrap and setting a max-width (e.g., max-w-xs or max-w-sm) to allow wrapping
  2. Adding overflow-hidden text-ellipsis with a reasonable max-width to truncate long notes with ellipsis
  3. Implementing a click-to-expand modal for longer notes
                                <div id="note-tooltip-{{.ID}}" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 max-w-xs p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200 z-10">
The tooltip uses `whitespace-nowrap` which prevents text wrapping and could cause very long notes to overflow off-screen or be cut off. This creates a poor user experience for subscriptions with lengthy notes. Consider either: 1. Removing `whitespace-nowrap` and setting a `max-width` (e.g., `max-w-xs` or `max-w-sm`) to allow wrapping 2. Adding `overflow-hidden text-ellipsis` with a reasonable max-width to truncate long notes with ellipsis 3. Implementing a click-to-expand modal for longer notes ```suggestion <div id="note-tooltip-{{.ID}}" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 max-w-xs p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200 z-10"> ```
@ -349,0 +351,4 @@
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:29:34 -05:00

The button has aria-describedby="note-tooltip-{{.ID}}" pointing to the tooltip div, but this is semantically incorrect. The aria-describedby attribute should reference descriptive text, not interactive content. Additionally, the button doesn't actually do anything when clicked - it only works on hover.

Consider either:

  1. Making this a non-interactive element (e.g., <span> with role="img") if it's purely visual
  2. Adding click functionality to toggle the tooltip for better mobile/touch support
  3. Using aria-label alone without aria-describedby if the tooltip is purely supplementary

The button has `aria-describedby="note-tooltip-{{.ID}}"` pointing to the tooltip div, but this is semantically incorrect. The `aria-describedby` attribute should reference descriptive text, not interactive content. Additionally, the button doesn't actually do anything when clicked - it only works on hover. Consider either: 1. Making this a non-interactive element (e.g., `<span>` with `role="img"`) if it's purely visual 2. Adding click functionality to toggle the tooltip for better mobile/touch support 3. Using `aria-label` alone without `aria-describedby` if the tooltip is purely supplementary ```suggestion ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2025-11-15 16:43:45 -05:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull Request Overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull Request Overview Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments. --- 💡 <a href="/bscott/subtrackr/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
@ -49,0 +57,4 @@
GIT_COMMIT=$(git rev-parse --short HEAD)
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:43:45 -05:00

The workflow uses git describe --tags but the checkout action doesn't fetch tags or full git history. By default, actions/checkout@v4 only fetches the last commit with fetch-depth: 1. This will cause git describe --tags --abbrev=0 to fail and always return "dev".

Add fetch-depth: 0 to the checkout step to fetch full git history and tags:

- name: Checkout repository
  uses: actions/checkout@v4
  with:
    fetch-depth: 0
The workflow uses `git describe --tags` but the checkout action doesn't fetch tags or full git history. By default, `actions/checkout@v4` only fetches the last commit with `fetch-depth: 1`. This will cause `git describe --tags --abbrev=0` to fail and always return "dev". Add `fetch-depth: 0` to the checkout step to fetch full git history and tags: ```yaml - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 ```
@ -56,0 +64,4 @@
GIT_COMMIT=$(git rev-parse --short HEAD)
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:43:44 -05:00

The workflow uses git describe --tags but the checkout action doesn't fetch tags or full git history. By default, actions/checkout@v4 only fetches the last commit with fetch-depth: 1. This will cause git describe --tags --abbrev=0 to fail and always return "dev".

Add fetch-depth: 0 to the checkout step to fetch full git history and tags:

- name: Checkout repository
  uses: actions/checkout@v4
  with:
    fetch-depth: 0
The workflow uses `git describe --tags` but the checkout action doesn't fetch tags or full git history. By default, `actions/checkout@v4` only fetches the last commit with `fetch-depth: 1`. This will cause `git describe --tags --abbrev=0` to fail and always return "dev". Add `fetch-depth: 0` to the checkout step to fetch full git history and tags: ```yaml - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 ```
@ -323,3 +325,91 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
v1.GET("/export/json", handler.ExportJSON)
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:43:44 -05:00

This implementation will send duplicate reminders daily for the same subscription until it renews. For example, if a subscription renews in 5 days, it will receive a reminder today (5 days), tomorrow (4 days), the next day (3 days), etc.

Consider tracking which reminders have been sent (e.g., adding a last_reminder_sent field to the subscription model) to avoid sending multiple reminders for the same renewal period, or only send reminders at specific intervals (e.g., only when exactly 7, 3, and 1 days remain).

	// Only send reminders at specific intervals, e.g., 7, 3, 1 days before renewal
	reminderIntervals := []int{7, 3, 1}
	for sub, daysUntil := range subscriptions {
		shouldSend := false
		for _, interval := range reminderIntervals {
			if daysUntil == interval && sub.LastReminderSentDays != interval {
				shouldSend = true
				break
			}
		}
		if shouldSend {
			err := emailService.SendRenewalReminder(sub, daysUntil)
			if err != nil {
				log.Printf("Error sending renewal reminder for subscription %s (ID: %d): %v", sub.Name, sub.ID, err)
				failedCount++
			} else {
				log.Printf("Sent renewal reminder for subscription %s (renews in %d days)", sub.Name, daysUntil)
				sentCount++
				// Update the last reminder sent interval
				updateErr := subscriptionService.UpdateLastReminderSentDays(sub, daysUntil)
				if updateErr != nil {
					log.Printf("Error updating last reminder sent for subscription %s (ID: %d): %v", sub.Name, sub.ID, updateErr)
				}
			}
		}
This implementation will send duplicate reminders daily for the same subscription until it renews. For example, if a subscription renews in 5 days, it will receive a reminder today (5 days), tomorrow (4 days), the next day (3 days), etc. Consider tracking which reminders have been sent (e.g., adding a `last_reminder_sent` field to the subscription model) to avoid sending multiple reminders for the same renewal period, or only send reminders at specific intervals (e.g., only when exactly 7, 3, and 1 days remain). ```suggestion // Only send reminders at specific intervals, e.g., 7, 3, 1 days before renewal reminderIntervals := []int{7, 3, 1} for sub, daysUntil := range subscriptions { shouldSend := false for _, interval := range reminderIntervals { if daysUntil == interval && sub.LastReminderSentDays != interval { shouldSend = true break } } if shouldSend { err := emailService.SendRenewalReminder(sub, daysUntil) if err != nil { log.Printf("Error sending renewal reminder for subscription %s (ID: %d): %v", sub.Name, sub.ID, err) failedCount++ } else { log.Printf("Sent renewal reminder for subscription %s (renews in %d days)", sub.Name, daysUntil) sentCount++ // Update the last reminder sent interval updateErr := subscriptionService.UpdateLastReminderSentDays(sub, daysUntil) if updateErr != nil { log.Printf("Error updating last reminder sent for subscription %s (ID: %d): %v", sub.Name, sub.ID, updateErr) } } } ```
@ -93,3 +94,46 @@ func (s *SubscriptionService) GetStats() (*models.Stats, error) {
func (s *SubscriptionService) GetAllCategories() ([]models.Category, error) {
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:43:44 -05:00

The days calculation using int(sub.RenewalDate.Sub(now).Hours() / 24) is imprecise and can lead to off-by-one errors due to floating-point truncation. For example, 23.5 hours will be calculated as 0 days when it should be considered as "renewing today".

Consider using a more precise date-based calculation:

// Truncate to start of day for accurate day counting
renewalDay := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month(), sub.RenewalDate.Day(), 0, 0, 0, 0, sub.RenewalDate.Location())
todayDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
daysUntil := int(renewalDay.Sub(todayDay).Hours() / 24)
		// Calculate days until renewal using date truncation for accuracy
		renewalDay := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month(), sub.RenewalDate.Day(), 0, 0, 0, 0, sub.RenewalDate.Location())
		todayDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
		daysUntil := int(renewalDay.Sub(todayDay).Hours() / 24)
The days calculation using `int(sub.RenewalDate.Sub(now).Hours() / 24)` is imprecise and can lead to off-by-one errors due to floating-point truncation. For example, 23.5 hours will be calculated as 0 days when it should be considered as "renewing today". Consider using a more precise date-based calculation: ```go // Truncate to start of day for accurate day counting renewalDay := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month(), sub.RenewalDate.Day(), 0, 0, 0, 0, sub.RenewalDate.Location()) todayDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) daysUntil := int(renewalDay.Sub(todayDay).Hours() / 24) ``` ```suggestion // Calculate days until renewal using date truncation for accuracy renewalDay := time.Date(sub.RenewalDate.Year(), sub.RenewalDate.Month(), sub.RenewalDate.Day(), 0, 0, 0, 0, sub.RenewalDate.Location()) todayDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) daysUntil := int(renewalDay.Sub(todayDay).Hours() / 24) ```
@ -349,0 +361,4 @@
{{end}}
<button
hx-get="/form/subscription/{{.ID}}"
hx-target="#modal-content"
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:43:45 -05:00

The tooltip button is not keyboard accessible. The button element doesn't handle keyboard interactions (Enter/Space keys) and relies solely on CSS :hover for tooltip display. Users navigating with keyboards won't be able to view the tooltip content.

Consider adding JavaScript to handle keyboard events or using a tabindex="0" along with :focus pseudo-class in addition to :hover for the tooltip display. The group-focus-within class is present but may not work as expected without proper focus handling on the button.

                                    class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150 focus:outline-none focus-visible:ring focus-visible:ring-primary/50"
                                    aria-label="View note"
                                    aria-describedby="note-tooltip-{{.ID}}"
                                    aria-haspopup="true"
                                    title="View note">
                                    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
                                    </svg>
                                </button>
                                <div id="note-tooltip-{{.ID}}" role="tooltip" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible focus-within:opacity-100 focus-within:visible transition-all duration-200 z-10 whitespace-nowrap">
                                    <p>{{.Notes}}</p>
                                    <div class="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div>
The tooltip button is not keyboard accessible. The button element doesn't handle keyboard interactions (Enter/Space keys) and relies solely on CSS `:hover` for tooltip display. Users navigating with keyboards won't be able to view the tooltip content. Consider adding JavaScript to handle keyboard events or using a `tabindex="0"` along with `:focus` pseudo-class in addition to `:hover` for the tooltip display. The `group-focus-within` class is present but may not work as expected without proper focus handling on the button. ```suggestion class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150 focus:outline-none focus-visible:ring focus-visible:ring-primary/50" aria-label="View note" aria-describedby="note-tooltip-{{.ID}}" aria-haspopup="true" title="View note"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path> </svg> </button> <div id="note-tooltip-{{.ID}}" role="tooltip" class="absolute right-0 bottom-full mb-2 w-auto min-w-0 p-1.5 bg-gray-900 dark:bg-gray-700 text-white dark:text-gray-100 text-xs rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible focus-within:opacity-100 focus-within:visible transition-all duration-200 z-10 whitespace-nowrap"> <p>{{.Notes}}</p> <div class="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-900 dark:border-t-gray-700"></div> ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2025-11-15 16:56:43 -05:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull Request Overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull Request Overview Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments. --- 💡 <a href="/bscott/subtrackr/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:56:42 -05:00

The release notes claim "Auto-sizing tooltip: Tooltip width automatically adjusts to match the note text length" (line 15), but the implementation uses whitespace-nowrap which prevents wrapping and could cause the tooltip to extend beyond the viewport for very long notes. This is misleading as it suggests the tooltip intelligently sizes itself, when in reality it grows indefinitely with the text length.

- **Tooltip sizing**: Tooltip displays full note text; long notes may extend horizontally
The release notes claim "Auto-sizing tooltip: Tooltip width automatically adjusts to match the note text length" (line 15), but the implementation uses `whitespace-nowrap` which prevents wrapping and could cause the tooltip to extend beyond the viewport for very long notes. This is misleading as it suggests the tooltip intelligently sizes itself, when in reality it grows indefinitely with the text length. ```suggestion - **Tooltip sizing**: Tooltip displays full note text; long notes may extend horizontally ```
@ -323,3 +325,91 @@ func setupRoutes(router *gin.Engine, handler *handlers.SubscriptionHandler, sett
v1.GET("/export/json", handler.ExportJSON)
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:56:42 -05:00

There's no mechanism to prevent sending duplicate reminder emails for the same subscription on the same day. If the server restarts multiple times in a day, or if the scheduler function is triggered multiple times, users could receive multiple reminder emails for the same subscription.

Consider adding a "last_reminder_sent" timestamp field to track when reminders were last sent, or implement a daily deduplication mechanism using a cache or database flag.

There's no mechanism to prevent sending duplicate reminder emails for the same subscription on the same day. If the server restarts multiple times in a day, or if the scheduler function is triggered multiple times, users could receive multiple reminder emails for the same subscription. Consider adding a "last_reminder_sent" timestamp field to track when reminders were last sent, or implement a daily deduplication mechanism using a cache or database flag.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:56:42 -05:00

The scheduler runs every 24 hours from server startup time, not at a specific time of day (e.g., midnight). This means if the server starts at 3 PM, reminders will be sent at 3 PM daily instead of at a consistent time like midnight.

Consider using a proper cron-like scheduler or calculating the time until the next midnight:

// Calculate time until next midnight
now := time.Now()
nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
timeUntilMidnight := nextMidnight.Sub(now)

// Wait until midnight, then run daily
time.Sleep(timeUntilMidnight)
checkAndSendRenewalReminders(subscriptionService, emailService, settingsService)

ticker := time.NewTicker(24 * time.Hour)
The scheduler runs every 24 hours from server startup time, not at a specific time of day (e.g., midnight). This means if the server starts at 3 PM, reminders will be sent at 3 PM daily instead of at a consistent time like midnight. Consider using a proper cron-like scheduler or calculating the time until the next midnight: ```go // Calculate time until next midnight now := time.Now() nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) timeUntilMidnight := nextMidnight.Sub(now) // Wait until midnight, then run daily time.Sleep(timeUntilMidnight) checkAndSendRenewalReminders(subscriptionService, emailService, settingsService) ticker := time.NewTicker(24 * time.Hour) ```
@ -96,0 +111,4 @@
result := make(map[*models.Subscription]int)
for i := range subscriptions {
sub := &subscriptions[i]
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:56:42 -05:00

The GetUpcomingRenewals repository method doesn't preload the Category relationship, which could cause issues when the email template tries to access .Category.Name in line 261 of email.go. Other similar repository methods like GetActiveSubscriptions and GetCancelledSubscriptions use .Preload("Category").

Add .Preload("Category") to the query in the repository method:

if err := r.db.Preload("Category").Where("status = ? AND renewal_date IS NOT NULL AND renewal_date BETWEEN ? AND ?",
    "Active", time.Now(), endDate).Find(&subscriptions).Error; err != nil {
The `GetUpcomingRenewals` repository method doesn't preload the `Category` relationship, which could cause issues when the email template tries to access `.Category.Name` in line 261 of email.go. Other similar repository methods like `GetActiveSubscriptions` and `GetCancelledSubscriptions` use `.Preload("Category")`. Add `.Preload("Category")` to the query in the repository method: ```go if err := r.db.Preload("Category").Where("status = ? AND renewal_date IS NOT NULL AND renewal_date BETWEEN ? AND ?", "Active", time.Now(), endDate).Find(&subscriptions).Error; err != nil { ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 16:56:42 -05:00

[nitpick] The function returns "dev" as a fallback when both Version and GitCommit are empty or their default values, but this could mask deployment issues where version information fails to be injected during build.

Consider logging a warning when falling back to "dev" in production environments, or returning an error to make version injection failures more visible during deployment.

[nitpick] The function returns "dev" as a fallback when both Version and GitCommit are empty or their default values, but this could mask deployment issues where version information fails to be injected during build. Consider logging a warning when falling back to "dev" in production environments, or returning an error to make version injection failures more visible during deployment.
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2025-11-15 17:15:52 -05:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull Request Overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

internal/repository/subscription.go:248

  • The GetUpcomingRenewals method doesn't preload the Category association, but the email template in SendRenewalReminder (line 261 of email.go) accesses .Category.Name. This will cause N+1 query issues when sending multiple reminders. Add .Preload(\"Category\") to the query chain, similar to GetActiveSubscriptions() at line 226.
func (r *SubscriptionRepository) GetUpcomingRenewals(days int) ([]models.Subscription, error) {
	var subscriptions []models.Subscription
	endDate := time.Now().AddDate(0, 0, days)

	if err := r.db.Where("status = ? AND renewal_date IS NOT NULL AND renewal_date BETWEEN ? AND ?",
		"Active", time.Now(), endDate).Find(&subscriptions).Error; err != nil {
		return nil, err
	}
	return subscriptions, nil

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull Request Overview Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments. <details> <summary>Comments suppressed due to low confidence (1)</summary> **internal/repository/subscription.go:248** * The `GetUpcomingRenewals` method doesn't preload the Category association, but the email template in `SendRenewalReminder` (line 261 of email.go) accesses `.Category.Name`. This will cause N+1 query issues when sending multiple reminders. Add `.Preload(\"Category\")` to the query chain, similar to `GetActiveSubscriptions()` at line 226. ``` func (r *SubscriptionRepository) GetUpcomingRenewals(days int) ([]models.Subscription, error) { var subscriptions []models.Subscription endDate := time.Now().AddDate(0, 0, days) if err := r.db.Where("status = ? AND renewal_date IS NOT NULL AND renewal_date BETWEEN ? AND ?", "Active", time.Now(), endDate).Find(&subscriptions).Error; err != nil { return nil, err } return subscriptions, nil ``` </details> --- 💡 <a href="/bscott/subtrackr/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:15:51 -05:00

The release notes incorrectly state 'No database migrations required', but the code includes a new migration function migrateReminderTracking in internal/database/migrations.go that adds two new columns (last_reminder_sent and last_reminder_renewal_date) to the subscriptions table.

**Database migration required:** This release adds two new columns (`last_reminder_sent` and `last_reminder_renewal_date`) to the `subscriptions` table. Please run the migration process to update your database schema before starting the new version.
The release notes incorrectly state 'No database migrations required', but the code includes a new migration function `migrateReminderTracking` in `internal/database/migrations.go` that adds two new columns (`last_reminder_sent` and `last_reminder_renewal_date`) to the subscriptions table. ```suggestion **Database migration required:** This release adds two new columns (`last_reminder_sent` and `last_reminder_renewal_date`) to the `subscriptions` table. Please run the migration process to update your database schema before starting the new version. ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:15:52 -05:00

The comment says 'run daily at midnight' but the ticker runs every 24 hours from the server start time, not at a specific time like midnight. If the server starts at 3 PM, reminders will run at 3 PM daily. Update the comment to accurately reflect this behavior, or implement actual midnight scheduling if that's the intended design.

The comment says 'run daily at midnight' but the ticker runs every 24 hours from the server start time, not at a specific time like midnight. If the server starts at 3 PM, reminders will run at 3 PM daily. Update the comment to accurately reflect this behavior, or implement actual midnight scheduling if that's the intended design.
@ -349,0 +362,4 @@
<button
hx-get="/form/subscription/{{.ID}}"
hx-target="#modal-content"
hx-trigger="click"
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:15:52 -05:00

The tooltip HTML structure (lines 344-361) is duplicated identically in both subscriptions.html and subscription-list.html (lines 161-178). Consider extracting this into a reusable template partial or component to improve maintainability and ensure consistency if changes are needed in the future.

                                {{template "partials/note-tooltip.html" .}}
The tooltip HTML structure (lines 344-361) is duplicated identically in both `subscriptions.html` and `subscription-list.html` (lines 161-178). Consider extracting this into a reusable template partial or component to improve maintainability and ensure consistency if changes are needed in the future. ```suggestion {{template "partials/note-tooltip.html" .}} ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2025-11-15 17:27:26 -05:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull Request Overview

Copilot reviewed 22 out of 22 changed files in this pull request and generated 11 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull Request Overview Copilot reviewed 22 out of 22 changed files in this pull request and generated 11 comments. --- 💡 <a href="/bscott/subtrackr/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:25 -05:00

Missing documentation for the new HighCostThreshold field in the settings. The UI change adds a configurable threshold input, but this feature is not mentioned in the release notes or feature list. Users upgrading to v0.4.8 won't be aware of this new capability.

Missing documentation for the new `HighCostThreshold` field in the settings. The UI change adds a configurable threshold input, but this feature is not mentioned in the release notes or feature list. Users upgrading to v0.4.8 won't be aware of this new capability.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:24 -05:00

The scheduler spawns two separate goroutines (lines 333 and 340) that could potentially run checkAndSendRenewalReminders concurrently. If the initial check (after 30 seconds) takes longer than expected, it might still be running when the ticker fires. While the current implementation has panic recovery, concurrent execution could lead to duplicate reminder emails being sent. Consider using a mutex or checking if a reminder run is already in progress before starting a new one.

The scheduler spawns two separate goroutines (lines 333 and 340) that could potentially run `checkAndSendRenewalReminders` concurrently. If the initial check (after 30 seconds) takes longer than expected, it might still be running when the ticker fires. While the current implementation has panic recovery, concurrent execution could lead to duplicate reminder emails being sent. Consider using a mutex or checking if a reminder run is already in progress before starting a new one.
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:24 -05:00

The migration silently continues even if adding columns fails (using log.Printf with "Note:" prefix). This could lead to runtime errors if the application expects these columns but they don't exist. Consider returning the error or at least using a more prominent warning level, especially since these fields are critical for the renewal reminder feature to work correctly.

The migration silently continues even if adding columns fails (using `log.Printf` with "Note:" prefix). This could lead to runtime errors if the application expects these columns but they don't exist. Consider returning the error or at least using a more prominent warning level, especially since these fields are critical for the renewal reminder feature to work correctly.
@ -209,1 +209,4 @@
case "threshold":
thresholdStr := c.PostForm("high_cost_threshold")
if threshold, err := strconv.ParseFloat(thresholdStr, 64); err == nil && threshold >= 0 && threshold <= 10000 {
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:24 -05:00

[nitpick] The validation allows threshold values between 0 and 10000, but a threshold of 0 doesn't make practical sense (every subscription would be considered high-cost). Consider using a minimum value of 0.01 or 1.0 instead, or document that 0 effectively disables high-cost alerts.

[nitpick] The validation allows threshold values between 0 and 10000, but a threshold of 0 doesn't make practical sense (every subscription would be considered high-cost). Consider using a minimum value of 0.01 or 1.0 instead, or document that 0 effectively disables high-cost alerts.
@ -83,6 +83,35 @@ func (h *SubscriptionHandler) enrichWithCurrencyConversion(subscriptions []model
return result
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:24 -05:00

The error handling on line 104 falls back to direct comparison when currency conversion fails, but this could produce incorrect results. If a subscription's monthly cost is 40 EUR and the threshold is 50 USD, failing to convert would compare 40 > 50 (false), when the actual converted value might be 43 USD > 50 USD (still false) or vice versa depending on rates. Consider either returning an error or using a more conservative approach (e.g., always treating as high-cost when conversion fails, to avoid missing alerts).

		// If conversion fails, conservatively treat as high-cost to avoid missing alerts
		log.Printf("Warning: Failed to convert currency for high-cost check: %v", err)
		return true
The error handling on line 104 falls back to direct comparison when currency conversion fails, but this could produce incorrect results. If a subscription's monthly cost is 40 EUR and the threshold is 50 USD, failing to convert would compare 40 > 50 (false), when the actual converted value might be 43 USD > 50 USD (still false) or vice versa depending on rates. Consider either returning an error or using a more conservative approach (e.g., always treating as high-cost when conversion fails, to avoid missing alerts). ```suggestion // If conversion fails, conservatively treat as high-cost to avoid missing alerts log.Printf("Warning: Failed to convert currency for high-cost check: %v", err) return true ```
@ -26,6 +26,8 @@ type Subscription struct {
Notes string `json:"notes" gorm:""`
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:25 -05:00

[nitpick] The comment on line 29 says this field "Tracks when last reminder was sent for current renewal date" but it actually tracks when the last reminder was sent, regardless of renewal date. The field that tracks which renewal date the reminder was for is LastReminderRenewalDate. Consider clarifying this comment to: "Tracks when the last reminder was sent" to avoid confusion.

	LastReminderSent       *time.Time `json:"last_reminder_sent" gorm:""` // Tracks when the last reminder was sent
[nitpick] The comment on line 29 says this field "Tracks when last reminder was sent for current renewal date" but it actually tracks when the last reminder was sent, regardless of renewal date. The field that tracks which renewal date the reminder was for is `LastReminderRenewalDate`. Consider clarifying this comment to: "Tracks when the last reminder was sent" to avoid confusion. ```suggestion LastReminderSent *time.Time `json:"last_reminder_sent" gorm:""` // Tracks when the last reminder was sent ```
@ -212,0 +220,4 @@
data := AlertData{
Subscription: subscription,
CurrencySymbol: currencySymbol,
}
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:26 -05:00

The email template uses a hard-coded dollar sign ($) on line 259, but the application supports multiple currencies through the CurrencySymbol setting. The email should use the user's configured currency symbol instead of assuming USD. Consider passing the currency symbol to the template or using a currency-aware formatting function.

The email template uses a hard-coded dollar sign ($) on line 259, but the application supports multiple currencies through the `CurrencySymbol` setting. The email should use the user's configured currency symbol instead of assuming USD. Consider passing the currency symbol to the template or using a currency-aware formatting function.
@ -0,0 +89,4 @@
},
},
expectedCount: 1,
description: "Should find subscription renewing today (within 24 hours)",
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:25 -05:00

[nitpick] The test description on line 92 says "Should find subscription renewing today (within 24 hours)" but the actual test creates a subscription renewing in 12 hours. The logic in the service would calculate this as int(12 hours / 24) = 0 days, which is correct. However, this highlights that the "0 days" case means "renews within the next 24 hours" rather than "renews today" in calendar terms. Consider clarifying the test description to reflect this hour-based calculation rather than day-based.

			description:   "Should find subscription renewing within the next 24 hours",
[nitpick] The test description on line 92 says "Should find subscription renewing today (within 24 hours)" but the actual test creates a subscription renewing in 12 hours. The logic in the service would calculate this as `int(12 hours / 24) = 0 days`, which is correct. However, this highlights that the "0 days" case means "renews within the next 24 hours" rather than "renews today" in calendar terms. Consider clarifying the test description to reflect this hour-based calculation rather than day-based. ```suggestion description: "Should find subscription renewing within the next 24 hours", ```
@ -96,0 +119,4 @@
// Calculate days until renewal using proper date arithmetic
// Use time.Until for more accurate calculation (handles timezone differences better)
daysUntil := int(time.Until(*sub.RenewalDate).Hours() / 24)
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:24 -05:00

[nitpick] The function iterates through all subscriptions returned by GetUpcomingRenewals() and creates pointers to array elements (sub := &subscriptions[i]). These pointers are then stored in the result map. This is safe, but note that the pointers reference elements in the subscriptions slice which lives on the stack/heap within this function. Since the map is returned, the slice won't be garbage collected. Consider whether it would be clearer to have GetUpcomingRenewals() return []*models.Subscription directly, or document this pointer behavior.

[nitpick] The function iterates through all subscriptions returned by `GetUpcomingRenewals()` and creates pointers to array elements (`sub := &subscriptions[i]`). These pointers are then stored in the result map. This is safe, but note that the pointers reference elements in the `subscriptions` slice which lives on the stack/heap within this function. Since the map is returned, the slice won't be garbage collected. Consider whether it would be clearer to have `GetUpcomingRenewals()` return `[]*models.Subscription` directly, or document this pointer behavior.
@ -96,0 +134,4 @@
result[sub] = daysUntil
}
}
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:23 -05:00

The duplicate check on lines 127-132 verifies if a reminder was already sent for the same renewal date, but this logic may fail if the renewal date changes slightly (e.g., by a few hours due to timezone adjustments). The Equal() method checks exact timestamp equality. Consider comparing just the date portion (year, month, day) instead of the full timestamp to handle cases where the renewal date time component might vary slightly.

				sub.LastReminderRenewalDate.Year() == sub.RenewalDate.Year() &&
				sub.LastReminderRenewalDate.Month() == sub.RenewalDate.Month() &&
				sub.LastReminderRenewalDate.Day() == sub.RenewalDate.Day() {
The duplicate check on lines 127-132 verifies if a reminder was already sent for the same renewal date, but this logic may fail if the renewal date changes slightly (e.g., by a few hours due to timezone adjustments). The `Equal()` method checks exact timestamp equality. Consider comparing just the date portion (year, month, day) instead of the full timestamp to handle cases where the renewal date time component might vary slightly. ```suggestion sub.LastReminderRenewalDate.Year() == sub.RenewalDate.Year() && sub.LastReminderRenewalDate.Month() == sub.RenewalDate.Month() && sub.LastReminderRenewalDate.Day() == sub.RenewalDate.Day() { ```
@ -8,2 +6,4 @@
// Version is the semantic version tag that will be set at build time
Version = "dev"
)
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:27:26 -05:00

[nitpick] The logic prioritizes semantic version over git commit, but the default value for Version changed from "v0.4.5" to "dev". This means existing deployments or builds without proper version injection will show "dev" instead of a meaningful version. Consider keeping a fallback to a last-known version (e.g., "v0.4.8") or document that proper build-time version injection is now required.

	Version = "v0.4.8" // fallback to last-known version if not set at build time
[nitpick] The logic prioritizes semantic version over git commit, but the default value for `Version` changed from "v0.4.5" to "dev". This means existing deployments or builds without proper version injection will show "dev" instead of a meaningful version. Consider keeping a fallback to a last-known version (e.g., "v0.4.8") or document that proper build-time version injection is now required. ```suggestion Version = "v0.4.8" // fallback to last-known version if not set at build time ```
copilot-pull-request-reviewer[bot] (Migrated from github.com) reviewed 2025-11-15 17:38:23 -05:00
copilot-pull-request-reviewer[bot] (Migrated from github.com) left a comment

Pull Request Overview

Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

## Pull Request Overview Copilot reviewed 20 out of 21 changed files in this pull request and generated 1 comment. --- 💡 <a href="/bscott/subtrackr/new/main/.github/instructions?filename=*.instructions.md" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Add Copilot custom instructions</a> for smarter, more guided reviews. <a href="https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot" class="Link--inTextBlock" target="_blank" rel="noopener noreferrer">Learn how to get started</a>.
@ -349,0 +352,4 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
</button>
copilot-pull-request-reviewer[bot] (Migrated from github.com) commented 2025-11-15 17:38:23 -05:00

The tooltip implementation could benefit from improved keyboard accessibility. Currently, the button triggers the tooltip on hover/focus (via CSS), but screen reader users and keyboard-only users may have difficulty accessing the tooltip content.

Consider the following improvements:

  1. Add type="button" to the button to prevent form submission if placed in a form context
  2. Consider adding role="tooltip" to the tooltip div for better semantic meaning
  3. The tooltip appears only on hover/focus, which is good, but ensure keyboard users can easily dismiss it

Example improvement:

<button 
    type="button"
    class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150"
    aria-label="View note"
    aria-describedby="note-tooltip-{{.ID}}"
    title="View note">
The tooltip implementation could benefit from improved keyboard accessibility. Currently, the button triggers the tooltip on hover/focus (via CSS), but screen reader users and keyboard-only users may have difficulty accessing the tooltip content. Consider the following improvements: 1. Add `type="button"` to the button to prevent form submission if placed in a form context 2. Consider adding `role="tooltip"` to the tooltip div for better semantic meaning 3. The tooltip appears only on hover/focus, which is good, but ensure keyboard users can easily dismiss it Example improvement: ```html <button type="button" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150" aria-label="View note" aria-describedby="note-tooltip-{{.ID}}" title="View note"> ```
bscott (Migrated from github.com) reviewed 2025-11-15 18:46:41 -05:00
@ -212,0 +220,4 @@
data := AlertData{
Subscription: subscription,
CurrencySymbol: currencySymbol,
}
bscott (Migrated from github.com) commented 2025-11-15 18:46:41 -05:00

@copilot open a new pull request to apply changes based on this feedback

@copilot open a new pull request to apply changes based on [this feedback](https://github.com/bscott/subtrackr/pull/58#discussion_r2530302326)
Copilot commented 2025-11-15 18:46:55 -05:00 (Migrated from github.com)

@bscott I've opened a new pull request, #59, to work on those changes. Once the pull request is ready, I'll request review from you.

@bscott I've opened a new pull request, #59, to work on those changes. Once the pull request is ready, I'll request review from you.
bscott (Migrated from github.com) reviewed 2025-11-15 18:53:28 -05:00
@ -212,0 +220,4 @@
data := AlertData{
Subscription: subscription,
CurrencySymbol: currencySymbol,
}
bscott (Migrated from github.com) commented 2025-11-15 18:53:28 -05:00

@copilot open a new pull request to apply changes based on this feedback

@copilot open a new pull request to apply changes based on [this feedback](https://github.com/bscott/subtrackr/pull/58#discussion_r2530154807)
Copilot commented 2025-11-15 18:53:37 -05:00 (Migrated from github.com)

@bscott I've opened a new pull request, #60, to work on those changes. Once the pull request is ready, I'll request review from you.

@bscott I've opened a new pull request, #60, to work on those changes. Once the pull request is ready, I'll request review from you.
bscott (Migrated from github.com) reviewed 2025-11-15 18:55:46 -05:00
@ -56,0 +64,4 @@
GIT_COMMIT=$(git rev-parse --short HEAD)
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
bscott (Migrated from github.com) commented 2025-11-15 18:55:46 -05:00

@copilot open a new pull request to apply changes based on this feedback

@copilot open a new pull request to apply changes based on [this feedback](https://github.com/bscott/subtrackr/pull/58#discussion_r2530182358)
Copilot commented 2025-11-15 18:55:53 -05:00 (Migrated from github.com)

@bscott I've opened a new pull request, #61, to work on those changes. Once the pull request is ready, I'll request review from you.

@bscott I've opened a new pull request, #61, to work on those changes. Once the pull request is ready, I'll request review from you.
bscott (Migrated from github.com) reviewed 2025-11-15 18:56:36 -05:00
@ -49,0 +57,4 @@
GIT_COMMIT=$(git rev-parse --short HEAD)
echo "tag=$GIT_TAG" >> $GITHUB_OUTPUT
echo "commit=$GIT_COMMIT" >> $GITHUB_OUTPUT
bscott (Migrated from github.com) commented 2025-11-15 18:56:36 -05:00

@copilot open a new pull request to apply changes based on this feedback

@copilot open a new pull request to apply changes based on [this feedback](https://github.com/bscott/subtrackr/pull/58#discussion_r2530182394)
Copilot commented 2025-11-15 18:56:46 -05:00 (Migrated from github.com)

@bscott I've opened a new pull request, #62, to work on those changes. Once the pull request is ready, I'll request review from you.

@bscott I've opened a new pull request, #62, to work on those changes. Once the pull request is ready, I'll request review from you.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
bscott/subtrackr!58
No description provided.