feat: add eve chanting notif

This commit is contained in:
Krispire Admin
2026-06-08 16:28:15 +05:30
parent 39450c3266
commit 3902033508
5 changed files with 64 additions and 3 deletions
+21 -1
View File
@@ -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
}
+3 -2
View File
@@ -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 {
+31
View File
@@ -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 {