Skip to content

Commit af3d67d

Browse files
committed
Implement daily reminders
1 parent 6e4910f commit af3d67d

File tree

3 files changed

+190
-65
lines changed

3 files changed

+190
-65
lines changed

github.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ func (app *App) fetchPRsInternal(ctx context.Context, waitForTurn bool) (incomin
245245
continue
246246
}
247247
log.Printf("[GITHUB] Query completed: %s - found %d PRs", result.query, len(result.issues))
248-
248+
249249
// Deduplicate PRs based on URL
250250
for _, issue := range result.issues {
251251
url := issue.GetHTMLURL()

main.go

Lines changed: 166 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,19 @@ type TurnResult struct {
8989
WasFromCache bool // Track if this result came from cache
9090
}
9191

92+
// NotificationState tracks the last known state and notification time for a PR.
93+
type NotificationState struct {
94+
LastNotified time.Time
95+
WasBlocked bool
96+
}
97+
9298
// App holds the application state.
9399
type App struct {
94100
lastSuccessfulFetch time.Time
95101
turnClient *turn.Client
96102
currentUser *github.User
97103
previousBlockedPRs map[string]bool
104+
notificationHistory map[string]NotificationState // Track state and notification time per PR
98105
client *github.Client
99106
lastMenuState *MenuState
100107
targetUser string
@@ -110,6 +117,7 @@ type App struct {
110117
noCache bool
111118
hideStaleIncoming bool
112119
loadingTurnData bool
120+
enableReminders bool // Whether to send daily reminder notifications
113121
}
114122

115123
func main() {
@@ -145,13 +153,15 @@ func main() {
145153
}
146154

147155
app := &App{
148-
cacheDir: cacheDir,
149-
hideStaleIncoming: true,
150-
previousBlockedPRs: make(map[string]bool),
151-
targetUser: targetUser,
152-
noCache: noCache,
153-
updateInterval: updateInterval,
154-
pendingTurnResults: make([]TurnResult, 0),
156+
cacheDir: cacheDir,
157+
hideStaleIncoming: true,
158+
previousBlockedPRs: make(map[string]bool),
159+
notificationHistory: make(map[string]NotificationState),
160+
targetUser: targetUser,
161+
noCache: noCache,
162+
updateInterval: updateInterval,
163+
pendingTurnResults: make([]TurnResult, 0),
164+
enableReminders: true,
155165
}
156166

157167
log.Println("Initializing GitHub clients...")
@@ -524,96 +534,188 @@ func (app *App) updatePRsWithWait(ctx context.Context) {
524534
app.checkForNewlyBlockedPRs(ctx)
525535
}
526536

537+
// shouldNotifyForPR determines if we should send a notification for a PR.
538+
func shouldNotifyForPR(
539+
_ string,
540+
isBlocked bool,
541+
prevState NotificationState,
542+
hasHistory bool,
543+
reminderInterval time.Duration,
544+
enableReminders bool,
545+
) (shouldNotify bool, reason string) {
546+
if !hasHistory && isBlocked {
547+
return true, "newly blocked"
548+
}
549+
550+
if !hasHistory {
551+
return false, ""
552+
}
553+
554+
switch {
555+
case isBlocked && !prevState.WasBlocked:
556+
return true, "became blocked"
557+
case !isBlocked && prevState.WasBlocked:
558+
return false, "unblocked"
559+
case isBlocked && prevState.WasBlocked && enableReminders && time.Since(prevState.LastNotified) > reminderInterval:
560+
return true, "reminder"
561+
default:
562+
return false, ""
563+
}
564+
}
565+
566+
// processPRNotifications handles notification logic for a single PR.
567+
func (app *App) processPRNotifications(
568+
ctx context.Context,
569+
pr PR,
570+
isBlocked bool,
571+
isIncoming bool,
572+
notificationHistory map[string]NotificationState,
573+
playedSound *bool,
574+
now time.Time,
575+
reminderInterval time.Duration,
576+
) {
577+
prevState, hasHistory := notificationHistory[pr.URL]
578+
shouldNotify, notifyReason := shouldNotifyForPR(pr.URL, isBlocked, prevState, hasHistory, reminderInterval, app.enableReminders)
579+
580+
// Update state for unblocked PRs
581+
if notifyReason == "unblocked" {
582+
notificationHistory[pr.URL] = NotificationState{
583+
LastNotified: prevState.LastNotified,
584+
WasBlocked: false,
585+
}
586+
return
587+
}
588+
589+
if !shouldNotify || !isBlocked {
590+
return
591+
}
592+
593+
// Send notification
594+
var title, soundType string
595+
if isIncoming {
596+
title = "PR Blocked on You 🪿"
597+
soundType = "honk"
598+
if notifyReason == "reminder" {
599+
log.Printf("[NOTIFY] Incoming PR reminder (24hr): %s #%d - %s (reason: %s)",
600+
pr.Repository, pr.Number, pr.Title, pr.ActionReason)
601+
} else {
602+
log.Printf("[NOTIFY] Incoming PR notification (%s): %s #%d - %s (reason: %s)",
603+
notifyReason, pr.Repository, pr.Number, pr.Title, pr.ActionReason)
604+
}
605+
} else {
606+
title = "Your PR is Blocked 🚀"
607+
soundType = "rocket"
608+
if notifyReason == "reminder" {
609+
log.Printf("[NOTIFY] Outgoing PR reminder (24hr): %s #%d - %s (reason: %s)",
610+
pr.Repository, pr.Number, pr.Title, pr.ActionReason)
611+
} else {
612+
log.Printf("[NOTIFY] Outgoing PR notification (%s): %s #%d - %s (reason: %s)",
613+
notifyReason, pr.Repository, pr.Number, pr.Title, pr.ActionReason)
614+
}
615+
}
616+
617+
message := fmt.Sprintf("%s #%d: %s", pr.Repository, pr.Number, pr.Title)
618+
if err := beeep.Notify(title, message, ""); err != nil {
619+
log.Printf("[NOTIFY] Failed to send desktop notification for %s: %v", pr.URL, err)
620+
} else {
621+
log.Printf("[NOTIFY] Desktop notification sent for %s", pr.URL)
622+
notificationHistory[pr.URL] = NotificationState{
623+
LastNotified: now,
624+
WasBlocked: true,
625+
}
626+
}
627+
628+
// Play sound once per polling period
629+
if !*playedSound {
630+
if notifyReason == "reminder" {
631+
log.Printf("[SOUND] Playing %s sound for daily reminder", soundType)
632+
}
633+
app.playSound(ctx, soundType)
634+
*playedSound = true
635+
}
636+
}
637+
527638
// checkForNewlyBlockedPRs checks for PRs that have become blocked and sends notifications.
528639
func (app *App) checkForNewlyBlockedPRs(ctx context.Context) {
529640
app.mu.Lock()
530-
oldBlockedPRs := app.previousBlockedPRs
531-
if oldBlockedPRs == nil {
532-
oldBlockedPRs = make(map[string]bool)
641+
notificationHistory := app.notificationHistory
642+
if notificationHistory == nil {
643+
notificationHistory = make(map[string]NotificationState)
644+
app.notificationHistory = notificationHistory
533645
}
534646
initialLoadComplete := app.initialLoadComplete
535647
hideStaleIncoming := app.hideStaleIncoming
536648
incoming := make([]PR, len(app.incoming))
537649
copy(incoming, app.incoming)
538650
outgoing := make([]PR, len(app.outgoing))
539651
copy(outgoing, app.outgoing)
652+
hasValidData := len(incoming) > 0 || len(outgoing) > 0
540653
app.mu.Unlock()
541654

542-
// Only log when we're checking after initial load is complete
543-
if initialLoadComplete && len(oldBlockedPRs) > 0 {
544-
log.Printf("[NOTIFY] Checking for newly blocked PRs (oldBlockedCount=%d)", len(oldBlockedPRs))
655+
// Skip if this looks like a transient GitHub API failure
656+
if !hasValidData && initialLoadComplete {
657+
log.Print("[NOTIFY] Skipping notification check - no PR data (likely transient API failure)")
658+
return
545659
}
546660

547661
// Calculate stale threshold
548662
now := time.Now()
549663
staleThreshold := now.Add(-stalePRThreshold)
550664

665+
// Reminder interval for re-notifications (24 hours)
666+
const reminderInterval = 24 * time.Hour
667+
551668
currentBlockedPRs := make(map[string]bool)
552669
playedIncomingSound := false
553670
playedOutgoingSound := false
554671

555-
// Check incoming PRs for newly blocked ones
556-
for i := range incoming {
557-
if incoming[i].NeedsReview {
558-
currentBlockedPRs[incoming[i].URL] = true
672+
// Check incoming PRs for state changes
673+
for idx := range incoming {
674+
pr := incoming[idx]
675+
isBlocked := pr.NeedsReview
559676

560-
// Skip stale PRs for notifications if hideStaleIncoming is enabled
561-
if hideStaleIncoming && incoming[i].UpdatedAt.Before(staleThreshold) {
562-
continue
563-
}
677+
if isBlocked {
678+
currentBlockedPRs[pr.URL] = true
679+
}
564680

565-
// Send notification and play sound if PR wasn't blocked before
566-
if !oldBlockedPRs[incoming[i].URL] {
567-
log.Printf("[NOTIFY] New blocked incoming PR: %s #%d - %s (reason: %s)",
568-
incoming[i].Repository, incoming[i].Number, incoming[i].Title, incoming[i].ActionReason)
569-
title := "PR Blocked on You 🪿"
570-
message := fmt.Sprintf("%s #%d: %s", incoming[i].Repository, incoming[i].Number, incoming[i].Title)
571-
if err := beeep.Notify(title, message, ""); err != nil {
572-
log.Printf("[NOTIFY] Failed to send desktop notification for %s: %v", incoming[i].URL, err)
573-
} else {
574-
log.Printf("[NOTIFY] Desktop notification sent for %s", incoming[i].URL)
575-
}
576-
// Only play sound once per polling period
577-
if !playedIncomingSound {
578-
app.playSound(ctx, "honk")
579-
playedIncomingSound = true
580-
}
581-
}
681+
// Skip stale PRs for notifications if hideStaleIncoming is enabled
682+
if hideStaleIncoming && pr.UpdatedAt.Before(staleThreshold) {
683+
continue
582684
}
685+
686+
app.processPRNotifications(ctx, pr, isBlocked, true, notificationHistory,
687+
&playedIncomingSound, now, reminderInterval)
583688
}
584689

585-
// Check outgoing PRs for newly blocked ones
586-
for i := range outgoing {
587-
if outgoing[i].IsBlocked {
588-
currentBlockedPRs[outgoing[i].URL] = true
690+
// Check outgoing PRs for state changes
691+
for idx := range outgoing {
692+
pr := outgoing[idx]
693+
isBlocked := pr.IsBlocked
589694

590-
// Skip stale PRs for notifications if hideStaleIncoming is enabled
591-
if hideStaleIncoming && outgoing[i].UpdatedAt.Before(staleThreshold) {
592-
continue
593-
}
695+
if isBlocked {
696+
currentBlockedPRs[pr.URL] = true
697+
}
594698

595-
// Send notification and play sound if PR wasn't blocked before
596-
if !oldBlockedPRs[outgoing[i].URL] {
597-
log.Printf("[NOTIFY] New blocked outgoing PR: %s #%d - %s (reason: %s)",
598-
outgoing[i].Repository, outgoing[i].Number, outgoing[i].Title, outgoing[i].ActionReason)
599-
title := "Your PR is Blocked 🚀"
600-
message := fmt.Sprintf("%s #%d: %s", outgoing[i].Repository, outgoing[i].Number, outgoing[i].Title)
601-
if err := beeep.Notify(title, message, ""); err != nil {
602-
log.Printf("[NOTIFY] Failed to send desktop notification for %s: %v", outgoing[i].URL, err)
603-
} else {
604-
log.Printf("[NOTIFY] Desktop notification sent for %s", outgoing[i].URL)
605-
}
606-
// Only play sound once per polling period
607-
if !playedOutgoingSound {
608-
app.playSound(ctx, "rocket")
609-
playedOutgoingSound = true
610-
}
611-
}
699+
// Skip stale PRs for notifications if hideStaleIncoming is enabled
700+
if hideStaleIncoming && pr.UpdatedAt.Before(staleThreshold) {
701+
continue
702+
}
703+
704+
app.processPRNotifications(ctx, pr, isBlocked, false, notificationHistory,
705+
&playedOutgoingSound, now, reminderInterval)
706+
}
707+
708+
// Clean up old entries from notification history (older than 7 days)
709+
const historyRetentionDays = 7
710+
for url, state := range notificationHistory {
711+
if time.Since(state.LastNotified) > historyRetentionDays*24*time.Hour {
712+
delete(notificationHistory, url)
612713
}
613714
}
614715

615-
// Update the previous blocked PRs map
716+
// Update the notification history
616717
app.mu.Lock()
718+
app.notificationHistory = notificationHistory
617719
app.previousBlockedPRs = currentBlockedPRs
618720
app.mu.Unlock()
619721
}

ui.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,29 @@ func (app *App) addStaticMenuItems(ctx context.Context) {
306306
app.rebuildMenu(ctx)
307307
})
308308

309+
// Daily reminders
310+
// Add 'Daily reminders' option
311+
reminderItem := systray.AddMenuItem("Daily reminders", "Send reminder notifications for blocked PRs every 24 hours")
312+
app.mu.RLock()
313+
if app.enableReminders {
314+
reminderItem.Check()
315+
}
316+
app.mu.RUnlock()
317+
reminderItem.Click(func() {
318+
app.mu.Lock()
319+
app.enableReminders = !app.enableReminders
320+
enabled := app.enableReminders
321+
app.mu.Unlock()
322+
323+
if enabled {
324+
reminderItem.Check()
325+
log.Println("[SETTINGS] Daily reminders enabled")
326+
} else {
327+
reminderItem.Uncheck()
328+
log.Println("[SETTINGS] Daily reminders disabled")
329+
}
330+
})
331+
309332
// Add login item option (macOS only)
310333
addLoginItemUI(ctx, app)
311334

0 commit comments

Comments
 (0)