feat: add eve chanting notif
This commit is contained in:
@@ -28,6 +28,11 @@ type ReminderCandidate struct {
|
|||||||
LastChantDate *time.Time // most recent completed chant day (UTC), nil if never
|
LastChantDate *time.Time // most recent completed chant day (UTC), nil if never
|
||||||
ChantDoneToday bool
|
ChantDoneToday bool
|
||||||
QuoteDoneToday 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).
|
// 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),
|
AND l.log_date = (now() AT TIME ZONE 'UTC')::date),
|
||||||
EXISTS(SELECT 1 FROM posts p
|
EXISTS(SELECT 1 FROM posts p
|
||||||
WHERE p.user_id = u.id
|
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
|
FROM users u
|
||||||
WHERE u.sadhna_reminders_enabled = TRUE
|
WHERE u.sadhna_reminders_enabled = TRUE
|
||||||
AND (
|
AND (
|
||||||
u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
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_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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("due candidates: %w", err)
|
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.UserID, &c.Email, &c.TZ, &c.ChantTime, &c.QuoteTime,
|
||||||
&c.LastChantOn, &c.LastQuoteOn, &c.LastChantDate,
|
&c.LastChantOn, &c.LastQuoteOn, &c.LastChantDate,
|
||||||
&c.ChantDoneToday, &c.QuoteDoneToday,
|
&c.ChantDoneToday, &c.QuoteDoneToday,
|
||||||
|
&c.RoundsToday, &c.TargetRounds, &c.LastChantIncompleteOn,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan candidate: %w", err)
|
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)
|
`UPDATE users SET last_quote_reminder_on = $1 WHERE id = $2`, utcDate, userID)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ const (
|
|||||||
|
|
||||||
// Reminder kinds are delivered live (email + push + a websocket toast)
|
// Reminder kinds are delivered live (email + push + a websocket toast)
|
||||||
// but NOT persisted — they're ephemeral nudges, not history.
|
// but NOT persisted — they're ephemeral nudges, not history.
|
||||||
NotifChantReminder = "chant_reminder"
|
NotifChantReminder = "chant_reminder"
|
||||||
NotifQuoteReminder = "quote_reminder"
|
NotifQuoteReminder = "quote_reminder"
|
||||||
|
NotifChantIncomplete = "chant_incomplete_reminder"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationService struct {
|
type NotificationService struct {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -37,6 +38,10 @@ const (
|
|||||||
// the overlap.
|
// the overlap.
|
||||||
reminderWindowMin = 20
|
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."
|
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."
|
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."
|
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.
|
// Quote reminder.
|
||||||
if !c.QuoteDoneToday && notRemindedToday(c.LastQuoteOn, utcToday) {
|
if !c.QuoteDoneToday && notRemindedToday(c.LastQuoteOn, utcToday) {
|
||||||
if t := parseHHMM(c.QuoteTime); t >= 0 && withinWindow(localMin, t) {
|
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
|
// notRemindedToday is true when no reminder of this kind has gone out on the
|
||||||
// current UTC day.
|
// current UTC day.
|
||||||
func notRemindedToday(last *time.Time, utcToday time.Time) bool {
|
func notRemindedToday(last *time.Time, utcToday time.Time) bool {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS last_chant_incomplete_reminder_on;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user