diff --git a/internal/repository/reminder_repo.go b/internal/repository/reminder_repo.go index d1cb8a2..ff724b8 100644 --- a/internal/repository/reminder_repo.go +++ b/internal/repository/reminder_repo.go @@ -28,6 +28,11 @@ type ReminderCandidate struct { LastChantDate *time.Time // most recent completed chant day (UTC), nil if never ChantDoneToday bool QuoteDoneToday bool + + // For the evening "incomplete chanting" reminder. + RoundsToday int // rounds chanted so far today + TargetRounds int // the user's daily target (16 if unset) + LastChantIncompleteOn *time.Time // UTC date the evening reminder last fired } // SetTimezone records the user's IANA timezone (validated by the caller). @@ -80,12 +85,18 @@ func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, AND l.log_date = (now() AT TIME ZONE 'UTC')::date), EXISTS(SELECT 1 FROM posts p WHERE p.user_id = u.id - AND (p.created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date) + AND (p.created_at AT TIME ZONE 'UTC')::date = (now() AT TIME ZONE 'UTC')::date), + COALESCE((SELECT l.rounds_completed FROM sadhna_logs l + WHERE l.user_id = u.id + AND l.log_date = (now() AT TIME ZONE 'UTC')::date), 0), + COALESCE(u.daily_chanting_rounds, 16), + u.last_chant_incomplete_reminder_on FROM users u WHERE u.sadhna_reminders_enabled = TRUE AND ( u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date + OR u.last_chant_incomplete_reminder_on IS NULL OR u.last_chant_incomplete_reminder_on < (now() AT TIME ZONE 'UTC')::date )`) if err != nil { return nil, fmt.Errorf("due candidates: %w", err) @@ -99,6 +110,7 @@ func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, &c.UserID, &c.Email, &c.TZ, &c.ChantTime, &c.QuoteTime, &c.LastChantOn, &c.LastQuoteOn, &c.LastChantDate, &c.ChantDoneToday, &c.QuoteDoneToday, + &c.RoundsToday, &c.TargetRounds, &c.LastChantIncompleteOn, ); err != nil { return nil, fmt.Errorf("scan candidate: %w", err) } @@ -120,3 +132,11 @@ func (r *ReminderRepo) MarkQuoteReminded(ctx context.Context, userID string, utc `UPDATE users SET last_quote_reminder_on = $1 WHERE id = $2`, utcDate, userID) return err } + +// MarkChantIncompleteReminded stamps the UTC date the evening "finish your +// rounds" reminder went out, so it fires at most once per day. +func (r *ReminderRepo) MarkChantIncompleteReminded(ctx context.Context, userID string, utcDate time.Time) error { + _, err := r.pg.Pool.Exec(ctx, + `UPDATE users SET last_chant_incomplete_reminder_on = $1 WHERE id = $2`, utcDate, userID) + return err +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 8461c20..e804195 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -32,8 +32,9 @@ const ( // Reminder kinds are delivered live (email + push + a websocket toast) // but NOT persisted — they're ephemeral nudges, not history. - NotifChantReminder = "chant_reminder" - NotifQuoteReminder = "quote_reminder" + NotifChantReminder = "chant_reminder" + NotifQuoteReminder = "quote_reminder" + NotifChantIncomplete = "chant_incomplete_reminder" ) type NotificationService struct { diff --git a/internal/service/reminder_service.go b/internal/service/reminder_service.go index e7d5378..9877637 100644 --- a/internal/service/reminder_service.go +++ b/internal/service/reminder_service.go @@ -15,6 +15,7 @@ package service import ( "context" "errors" + "fmt" "strconv" "strings" "time" @@ -37,6 +38,10 @@ const ( // the overlap. reminderWindowMin = 20 + // The evening "finish your rounds" reminder fires at 21:00 local. Kept + // before the 21:30 quote reminder so the two don't collide in one tick. + chantIncompleteTime = "21:00" + chantMsgStart = "Chant with Prabhupāda — start building your sādhana streak." chantMsgLapsed = "Just one hour of sādhana can save you from this duḥkhālayam aśāśvatam. Come and build your streak again." chantMsgYesterday = "Your sādhana was wonderful yesterday! Let's continue your sādhana today as well." @@ -112,6 +117,21 @@ func (s *ReminderService) RunOnce(ctx context.Context, now time.Time) { } } + // Evening "incomplete chanting" reminder — the devotee started their + // rounds today (RoundsToday > 0) but hasn't reached the target by + // 21:00 local. Only nudges those who began, so non-starters get just + // the morning reminder, not a second one. Independent of the morning + // chant reminder (own time + own per-day dedupe). + if c.RoundsToday > 0 && !c.ChantDoneToday && notRemindedToday(c.LastChantIncompleteOn, utcToday) { + if t := parseHHMM(chantIncompleteTime); t >= 0 && withinWindow(localMin, t) { + s.notif.SendReminder(ctx, c.UserID, NotifChantIncomplete, + "Finish your japa 🙏", incompleteChantMessage(c.RoundsToday, c.TargetRounds), s.sadhnaURL) + if err := s.repo.MarkChantIncompleteReminded(ctx, c.UserID, utcToday); err != nil { + s.logger.Warn("reminders: mark chant-incomplete failed", zap.String("user", c.UserID), zap.Error(err)) + } + } + } + // Quote reminder. if !c.QuoteDoneToday && notRemindedToday(c.LastQuoteOn, utcToday) { if t := parseHHMM(c.QuoteTime); t >= 0 && withinWindow(localMin, t) { @@ -143,6 +163,17 @@ func chantMessage(lastChant *time.Time, utcToday time.Time) string { } } +// incompleteChantMessage nudges a devotee who began their rounds today to +// finish them (only sent when rounds > 0). +func incompleteChantMessage(rounds, target int) string { + if target <= 0 { + target = 16 + } + return fmt.Sprintf( + "You've chanted %d of %d rounds today — finish your japa before the day ends. 🙏", + rounds, target) +} + // notRemindedToday is true when no reminder of this kind has gone out on the // current UTC day. func notRemindedToday(last *time.Time, utcToday time.Time) bool { diff --git a/migrations/0029_add_chant_incomplete_reminder.down.sql b/migrations/0029_add_chant_incomplete_reminder.down.sql new file mode 100644 index 0000000..b329c81 --- /dev/null +++ b/migrations/0029_add_chant_incomplete_reminder.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS last_chant_incomplete_reminder_on; diff --git a/migrations/0029_add_chant_incomplete_reminder.up.sql b/migrations/0029_add_chant_incomplete_reminder.up.sql new file mode 100644 index 0000000..fc85c38 --- /dev/null +++ b/migrations/0029_add_chant_incomplete_reminder.up.sql @@ -0,0 +1,8 @@ +-- Evening "incomplete chanting" reminder. +-- +-- last_chant_incomplete_reminder_on: the UTC date the 9 PM (local) +-- "finish your rounds" reminder was last sent, so it goes out at most once +-- per day. Distinct from the morning chant reminder so a devotee can get a +-- gentle start-of-day nudge and, if their rounds are still unfinished, an +-- end-of-day one. +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_chant_incomplete_reminder_on DATE;