feat: add web push and sadhna tracker

This commit is contained in:
Krispire Admin
2026-06-08 15:47:44 +05:30
parent e352948fb1
commit f598770b32
31 changed files with 2267 additions and 2 deletions
+96 -1
View File
@@ -29,6 +29,11 @@ import (
const (
NotifFollowRequest = "follow_request"
NotifFollowAccepted = "follow_accepted"
// 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"
)
type NotificationService struct {
@@ -36,16 +41,18 @@ type NotificationService struct {
users *repository.UserRepo
mail mailer.Mailer
rt *realtime.SoketiClient // optional — nil disables WebSocket push
push *PushSender // optional — nil disables Web Push
logger *zap.Logger
frontendURL string
}
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, logger *zap.Logger, frontendURL string) *NotificationService {
func NewNotificationService(notifs *repository.NotificationRepo, users *repository.UserRepo, mail mailer.Mailer, rt *realtime.SoketiClient, push *PushSender, logger *zap.Logger, frontendURL string) *NotificationService {
return &NotificationService{
notifs: notifs,
users: users,
mail: mail,
rt: rt,
push: push,
logger: logger,
frontendURL: strings.TrimRight(frontendURL, "/"),
}
@@ -109,6 +116,10 @@ func (s *NotificationService) dispatch(ctx context.Context, recipientID, actorID
// recipient has an email; authoritative data still comes from REST.
s.publishRealtime(ctx, recipientID, kind, actorUser)
// Web Push (best-effort) — reaches the recipient even with the app
// closed. Same seam as realtime/email; nil sender is a no-op.
s.publishPush(ctx, recipientID, kind, actorUser)
// Email (best-effort).
recipient, err := s.users.FindByID(ctx, recipientID)
if err != nil {
@@ -158,6 +169,73 @@ func (s *NotificationService) publishRealtime(ctx context.Context, recipientID,
}
}
// SendReminder delivers an ephemeral sādhana reminder across all live
// channels — a websocket toast (no unread bump, not persisted), a Web Push,
// and an email. Best-effort; nothing is persisted to the notification
// center. `url` is where tapping the notification lands.
func (s *NotificationService) SendReminder(ctx context.Context, userID, kind, title, body, url string) {
// Realtime toast — a distinct "reminder" event so the client toasts
// without bumping the unread badge (reminders have no DB row).
if s.rt != nil {
_ = s.rt.Trigger(ctx, "notifications."+userID, "reminder", map[string]any{
"kind": kind, "title": title, "body": body, "url": url,
})
}
// Web Push (reaches closed apps).
if s.push != nil {
s.push.SendToUser(ctx, userID, PushPayload{Title: title, Body: body, URL: url, Tag: kind})
}
// Email (only if there's an address on file).
recipient, err := s.users.FindByID(ctx, userID)
if err != nil || recipient == nil || recipient.Email == nil || strings.TrimSpace(*recipient.Email) == "" {
return
}
var buf bytes.Buffer
if err := reminderEmailTpl.Execute(&buf, map[string]any{
"RecipientName": recipient.Name,
"Message": body,
"URL": url,
}); err != nil {
s.logger.Warn("render reminder email failed", zap.String("kind", kind), zap.Error(err))
return
}
if err := s.mail.Send(strings.TrimSpace(*recipient.Email), title, buf.String()); err != nil {
s.logger.Warn("reminder email send failed", zap.String("to", *recipient.Email), zap.String("kind", kind), zap.Error(err))
}
}
// publishPush delivers a Web Push notification to the recipient's devices.
// The body quotes the actor; tapping it opens their profile (where a pending
// follow request can be accepted). Best-effort; nil sender is a no-op.
func (s *NotificationService) publishPush(ctx context.Context, recipientID, kind string, actor *model.User) {
if s.push == nil {
return
}
who := "Someone"
url := s.frontendURL + "/notifications"
if actor != nil {
who = actorDisplay(actor)
url = s.frontendURL + "/u/" + actor.Handle
}
var body string
switch kind {
case NotifFollowRequest:
body = who + " requested to follow you"
case NotifFollowAccepted:
body = who + " accepted your follow request"
default:
body = "You have a new notification"
}
s.push.SendToUser(ctx, recipientID, PushPayload{
Title: "Prabhupāda Says",
Body: body,
URL: url,
Tag: kind,
})
}
func (s *NotificationService) render(kind string, recipient, actor *model.User) (string, error) {
data := map[string]any{
"RecipientName": recipient.Name,
@@ -221,3 +299,20 @@ var followAcceptedEmailTpl = template.Must(template.New("followAccepted").Parse(
<p style="margin-top:1.5em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
// Daily sādhana reminder. The Message is the recency-aware nudge; the CTA
// opens the streak page.
var reminderEmailTpl = template.Must(template.New("reminder").Parse(`<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#2a2118;max-width:560px;margin:0 auto;font-size:17px;line-height:1.7">
<p style="font-size:18px;color:#9a6b2f;letter-spacing:0.04em;margin-bottom:1em">Hare Kṛṣṇa{{if .RecipientName}}, {{.RecipientName}}{{end}} 🙏</p>
<p>{{.Message}}</p>
<p style="margin:1.25em 0">
<a href="{{.URL}}" style="display:inline-block;background:#9a6b2f;color:#fff;text-decoration:none;padding:0.6em 1.3em;border-radius:8px;font-weight:600">Open your sādhana</a>
</p>
<p style="margin-top:1.5em;font-size:14px;color:#7a6a55">You can turn these reminders off any time in Settings → Notifications.</p>
<p style="margin-top:1em">Your servants,<br>
<strong>The Prabhupāda Says team</strong></p>
</div>`))
+98
View File
@@ -0,0 +1,98 @@
package service
import (
"context"
"encoding/json"
"net/http"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// PushPayload is the JSON the service worker receives in its `push` event.
type PushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
Tag string `json:"tag,omitempty"`
}
// PushSender delivers Web Push messages via VAPID. Construct with
// NewPushSender; it returns nil when VAPID isn't configured, and every method
// is nil-safe so callers needn't branch.
type PushSender struct {
repo *repository.PushRepo
pub string
priv string
subject string
logger *zap.Logger
}
// NewPushSender returns nil if the VAPID keys are absent (push disabled).
func NewPushSender(repo *repository.PushRepo, publicKey, privateKey, subject string, logger *zap.Logger) *PushSender {
if publicKey == "" || privateKey == "" || repo == nil {
return nil
}
return &PushSender{repo: repo, pub: publicKey, priv: privateKey, subject: subject, logger: logger}
}
// PublicKey is the VAPID application server key the browser subscribes with.
func (p *PushSender) PublicKey() string {
if p == nil {
return ""
}
return p.pub
}
// Enabled reports whether push delivery is configured.
func (p *PushSender) Enabled() bool { return p != nil }
// SendToUser pushes a payload to every subscription the user has, pruning any
// endpoint the push service reports as gone (404/410). Best-effort: failures
// are logged, never returned.
func (p *PushSender) SendToUser(ctx context.Context, userID string, payload PushPayload) {
if p == nil {
return
}
subs, err := p.repo.ListByUser(ctx, userID)
if err != nil {
p.logger.Warn("push: list subscriptions failed", zap.String("user", userID), zap.Error(err))
return
}
if len(subs) == 0 {
return
}
body, err := json.Marshal(payload)
if err != nil {
return
}
for _, s := range subs {
sub := &webpush.Subscription{
Endpoint: s.Endpoint,
Keys: webpush.Keys{P256dh: s.P256dh, Auth: s.Auth},
}
resp, err := webpush.SendNotification(body, sub, &webpush.Options{
Subscriber: p.subject,
VAPIDPublicKey: p.pub,
VAPIDPrivateKey: p.priv,
TTL: 60,
Urgency: webpush.UrgencyNormal,
})
if err != nil {
p.logger.Warn("push: send failed", zap.String("user", userID), zap.Error(err))
continue
}
status := resp.StatusCode
_ = resp.Body.Close()
// Subscription expired/gone — drop it so we stop trying.
if status == http.StatusNotFound || status == http.StatusGone {
if derr := p.repo.DeleteByEndpoint(ctx, s.Endpoint); derr != nil {
p.logger.Warn("push: prune failed", zap.Error(derr))
}
} else if status >= 400 {
p.logger.Warn("push: non-2xx", zap.Int("status", status), zap.String("user", userID))
}
}
}
+171
View File
@@ -0,0 +1,171 @@
// Package service — ReminderService drives the daily sādhana reminders.
//
// A worker calls RunOnce on a fixed tick. For each reminder-enabled user it
// computes the user's *local* clock time (from their IANA timezone) and, if
// it falls in the send window for that reminder AND they haven't done the
// discipline today AND they haven't already been reminded today, sends the
// nudge through every channel (email + push + websocket toast).
//
// "Done today" and the once-per-day dedupe are bucketed by UTC calendar day,
// matching the streak logic; only the *time of day* the reminder fires is
// local. The recency-aware chant message is chosen from the user's most
// recent completed chant day.
package service
import (
"context"
"errors"
"strconv"
"strings"
"time"
// Embed the IANA tz database so time.LoadLocation works regardless of
// whether the host image ships zoneinfo.
_ "time/tzdata"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// ErrInvalidTimezone is returned when a timezone-set request carries a value
// that isn't a loadable IANA zone.
var ErrInvalidTimezone = errors.New("invalid timezone")
const (
// reminderWindowMin must be >= the worker tick so no target time is
// missed between ticks; the per-day dedupe prevents a double-send within
// the overlap.
reminderWindowMin = 20
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."
quoteMsg = "Share a verse from the Vāṇī and complete today's sādhana."
)
type ReminderService struct {
repo *repository.ReminderRepo
notif *NotificationService
logger *zap.Logger
sadhnaURL string
}
func NewReminderService(repo *repository.ReminderRepo, notif *NotificationService, logger *zap.Logger, frontendURL string) *ReminderService {
return &ReminderService{
repo: repo,
notif: notif,
logger: logger,
sadhnaURL: strings.TrimRight(frontendURL, "/") + "/sadhna-streak",
}
}
// Enabled / SetEnabled / SetTimezone back the settings + tz-capture endpoints.
func (s *ReminderService) Enabled(ctx context.Context, userID string) (bool, error) {
return s.repo.GetEnabled(ctx, userID)
}
func (s *ReminderService) SetEnabled(ctx context.Context, userID string, enabled bool) error {
return s.repo.SetEnabled(ctx, userID, enabled)
}
// SetTimezone validates the IANA zone before storing so a bad value can never
// break the (timezone-using) candidate scan.
func (s *ReminderService) SetTimezone(ctx context.Context, userID, tz string) error {
tz = strings.TrimSpace(tz)
if tz == "" {
tz = "UTC"
}
if _, err := time.LoadLocation(tz); err != nil {
return ErrInvalidTimezone
}
return s.repo.SetTimezone(ctx, userID, tz)
}
// RunOnce evaluates every due candidate against `now` and fires the reminders
// that are due. Errors are logged, never returned — a bad row shouldn't stop
// the rest.
func (s *ReminderService) RunOnce(ctx context.Context, now time.Time) {
candidates, err := s.repo.DueCandidates(ctx)
if err != nil {
s.logger.Warn("reminders: load candidates failed", zap.Error(err))
return
}
utc := now.UTC()
utcToday := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
for _, c := range candidates {
loc, err := time.LoadLocation(c.TZ)
if err != nil {
loc = time.UTC
}
local := now.In(loc)
localMin := local.Hour()*60 + local.Minute()
// Chant reminder.
if !c.ChantDoneToday && notRemindedToday(c.LastChantOn, utcToday) {
if t := parseHHMM(c.ChantTime); t >= 0 && withinWindow(localMin, t) {
s.notif.SendReminder(ctx, c.UserID, NotifChantReminder,
"Time for your sādhana 🙏", chantMessage(c.LastChantDate, utcToday), s.sadhnaURL)
if err := s.repo.MarkChantReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark chant 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) {
s.notif.SendReminder(ctx, c.UserID, NotifQuoteReminder,
"Share a verse from the Vāṇī", quoteMsg, s.sadhnaURL)
if err := s.repo.MarkQuoteReminded(ctx, c.UserID, utcToday); err != nil {
s.logger.Warn("reminders: mark quote failed", zap.String("user", c.UserID), zap.Error(err))
}
}
}
}
}
// chantMessage picks the recency-aware nudge from the most recent completed
// chant day (both args are UTC midnight). The candidate is, by construction,
// not done today, so the gap is >= 1 day.
func chantMessage(lastChant *time.Time, utcToday time.Time) string {
if lastChant == nil {
return chantMsgStart
}
days := int(utcToday.Sub(*lastChant).Hours() / 24)
switch {
case days <= 1:
return chantMsgYesterday // chanted yesterday
case days <= 7:
return chantMsgLapsed // within the last week, but not yesterday
default:
return chantMsgStart // nothing in over a week
}
}
// 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 {
return last == nil || last.Before(utcToday)
}
// withinWindow reports whether localMin falls in [target, target+window),
// handling midnight wrap.
func withinWindow(localMin, target int) bool {
diff := ((localMin-target)%1440 + 1440) % 1440
return diff < reminderWindowMin
}
// parseHHMM turns "HH:MM" into minutes-of-day, or -1 if malformed.
func parseHHMM(s string) int {
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
if len(parts) != 2 {
return -1
}
h, err1 := strconv.Atoi(parts[0])
m, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return -1
}
return h*60 + m
}
+418
View File
@@ -0,0 +1,418 @@
// Package service — SadhnaService is the sādhana-streak brain. It folds a
// user's chanting logs (sadhna_logs) together with their quote posts
// (posts table) into the single dashboard the /sadhna-streak page reads:
// today's two disciplines, the trailing 7-day strip, the streak counters,
// and the growing-plant state.
//
// A day is "complete" only when BOTH daily disciplines were done that UTC
// calendar day: the target rounds were chanted AND at least one quote was
// posted. The streak counts consecutive complete days; the plant's growth
// is a 0–100 score that rises a little each complete day and falls faster
// for each missed day, eventually decaying to 0 ("dead") — and revives as
// soon as the practice resumes. The whole thing is recomputed from the
// underlying rows on every read, so there's no derived state to keep in
// sync and back-dated edits can never drift.
package service
import (
"context"
"math/rand"
"time"
"github.com/prabhupadasays/spsays-bff/internal/model"
"github.com/prabhupadasays/spsays-bff/internal/repository"
"go.uber.org/zap"
)
// Tunables for the streak + plant. Kept together so the growth feel is
// easy to adjust in one place.
const (
// defaultChantTarget is the fallback daily rounds when a user hasn't
// set daily_chanting_rounds on their profile (Śrīla Prabhupāda's
// minimum for initiated devotees).
defaultChantTarget = 16
// growthMax is the score at which the plant is a full mature tree.
growthMax = 100
// growthPerDay is added for each complete day (≈25 perfect days from
// seed to full tree).
growthPerDay = 4
// regressPerDay is subtracted for each missed day. Deliberately larger
// than growthPerDay so neglect undoes progress faster than practice
// builds it (a full tree withers to nothing in ~12 missed days).
regressPerDay = 9
// streakWindowDays bounds how far back the recompute walks. Comfortably
// longer than any growth/decay can persist, so the [0,100] clamp washes
// out the unknown state before the window's start.
streakWindowDays = 400
// vineMaxDays caps how many folds the creeper payload carries. The
// client only renders as many as fit the pole anyway; CurrentStreak
// still reports the true length.
vineMaxDays = 60
)
// rewardEveryDays is the streak cadence for gifts: one reward every 7
// completed days, counted from the start of the current unbroken streak
// (days 7, 14, 21, …). A broken streak restarts the count.
const rewardEveryDays = 7
// SadhnaService computes streak/plant state and records chanting progress.
type SadhnaService struct {
logs *repository.SadhnaRepo
posts *repository.PostRepo
users *repository.UserRepo
rewards *repository.RewardRepo
logger *zap.Logger
}
func NewSadhnaService(
logs *repository.SadhnaRepo,
posts *repository.PostRepo,
users *repository.UserRepo,
rewards *repository.RewardRepo,
logger *zap.Logger,
) *SadhnaService {
return &SadhnaService{logs: logs, posts: posts, users: users, rewards: rewards, logger: logger}
}
const dayFmt = "2006-01-02"
// utcToday returns today's UTC calendar date truncated to midnight, the
// anchor every day-bucket comparison uses.
func utcToday() time.Time {
now := time.Now().UTC()
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
}
// SaveChant records the user's chanting progress for today and returns the
// freshly recomputed dashboard. Rounds outside [0,1008] are clamped.
// chant_done is frozen as rounds >= the user's current target.
func (s *SadhnaService) SaveChant(ctx context.Context, userID string, rounds int) (*model.SadhnaDashboard, error) {
if rounds < 0 {
rounds = 0
}
if rounds > 1008 {
rounds = 1008
}
target := s.targetFor(ctx, userID)
chantDone := rounds >= target
if _, err := s.logs.UpsertToday(ctx, userID, utcToday(), rounds, chantDone); err != nil {
return nil, err
}
return s.Dashboard(ctx, userID)
}
// Dashboard recomputes the user's full sādhana state from their chanting
// logs and quote posts over the trailing window.
func (s *SadhnaService) Dashboard(ctx context.Context, userID string) (*model.SadhnaDashboard, error) {
target := s.targetFor(ctx, userID)
today := utcToday()
since := today.AddDate(0, 0, -(streakWindowDays - 1))
logs, err := s.logs.ListSince(ctx, userID, since)
if err != nil {
return nil, err
}
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since)
if err != nil {
return nil, err
}
// Index the chanting half by day.
chantByDay := make(map[string]bool, len(logs))
roundsByDay := make(map[string]int, len(logs))
for _, l := range logs {
key := l.LogDate.UTC().Format(dayFmt)
chantByDay[key] = l.ChantDone
roundsByDay[key] = l.RoundsCompleted
}
// posted[day] = at least one quote that day; postsByDay[day] = how many.
posted := func(day string) bool { return postsByDay[day] > 0 }
// completed[day] = both disciplines done that day.
completed := func(day string) bool { return chantByDay[day] && posted(day) }
todayKey := today.Format(dayFmt)
// --- Growth: forward-simulate the window, day by day. Today never
// regresses the plant (the day isn't over yet) — it only adds growth
// once complete. ---
growth := 0
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
key := d.Format(dayFmt)
isToday := key == todayKey
switch {
case completed(key):
growth += growthPerDay
if growth > growthMax {
growth = growthMax
}
case isToday:
// in-progress day — leave growth as-is
default:
growth -= regressPerDay
if growth < 0 {
growth = 0
}
}
}
// --- Streaks. Current streak ends at today if today is complete,
// otherwise at yesterday (so an unfinished today doesn't read as a
// broken streak mid-day). ---
current := 0
cursor := today
if !completed(todayKey) {
cursor = today.AddDate(0, 0, -1)
}
for !cursor.Before(since) && completed(cursor.Format(dayFmt)) {
current++
cursor = cursor.AddDate(0, 0, -1)
}
// Longest run of complete days anywhere in the window.
longest, run := 0, 0
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
if completed(d.Format(dayFmt)) {
run++
if run > longest {
longest = run
}
} else {
run = 0
}
}
if current > longest {
longest = current
}
// --- The trailing 7-day strip, oldest first. ---
week := make([]model.SadhnaDay, 0, 7)
for i := 6; i >= 0; i-- {
d := today.AddDate(0, 0, -i)
key := d.Format(dayFmt)
week = append(week, model.SadhnaDay{
Date: key,
ChantDone: chantByDay[key],
QuoteDone: posted(key),
Completed: completed(key),
})
}
// --- The creeper: one fold per day of the current streak (oldest
// first), plus a final in-progress fold for an unfinished today. The
// streak's complete days end at today when today is complete, else at
// yesterday; an unfinished today is appended as the growing tip. ---
vine := make([]model.VineDay, 0, current+1)
if current >= 1 {
end := today
if !completed(todayKey) {
end = today.AddDate(0, 0, -1)
}
start := end.AddDate(0, 0, -(current - 1))
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
key := d.Format(dayFmt)
vine = append(vine, model.VineDay{
Date: key,
Rounds: roundsByDay[key],
Posts: postsByDay[key],
Completed: true,
})
}
}
if !completed(todayKey) {
vine = append(vine, model.VineDay{
Date: todayKey,
Rounds: roundsByDay[todayKey],
Posts: postsByDay[todayKey],
InProgress: true,
})
}
if len(vine) > vineMaxDays {
vine = vine[len(vine)-vineMaxDays:]
}
// --- Rewards: a gift every 7 streak-days. When today completes a
// multiple-of-7 streak day, grant one (idempotently). Surface any
// not-yet-celebrated grant + the projected date of the next gift. ---
var pending *model.SadhnaRewardGrant
var nextReward *string
if s.rewards != nil {
todayDone := completed(todayKey)
if todayDone && current > 0 && current%rewardEveryDays == 0 {
s.ensureGrant(ctx, userID, current)
}
pending, _ = s.rewards.PendingGrant(ctx, userID)
nextReward = nextRewardDate(today, current, todayDone)
}
return &model.SadhnaDashboard{
TargetRounds: target,
RoundsCompleted: roundsByDay[todayKey],
ChantDone: chantByDay[todayKey],
QuoteDone: posted(todayKey),
TodayCompleted: completed(todayKey),
CurrentStreak: current,
LongestStreak: longest,
Growth: growth,
Stage: stageFor(growth),
Alive: growth > 0,
Week: week,
VineDays: vine,
NextRewardDate: nextReward,
PendingReward: pending,
}, nil
}
// ensureGrant idempotently awards a gift for the given streak milestone,
// picking a reward the user hasn't received yet. Once every reward has been
// collected the pool resets (avoiding an immediate back-to-back repeat) so
// the gifts cycle. Best-effort: any error leaves the milestone ungranted,
// to be retried on the next recompute.
func (s *SadhnaService) ensureGrant(ctx context.Context, userID string, milestone int) {
has, err := s.rewards.HasMilestone(ctx, userID, milestone)
if err != nil || has {
return
}
pool, err := s.rewards.ListGiftable(ctx)
if err != nil || len(pool) == 0 {
return
}
granted, err := s.rewards.GrantedRewardIDs(ctx, userID)
if err != nil {
return
}
// Prefer rewards not yet received.
cand := make([]model.SadhnaReward, 0, len(pool))
for _, r := range pool {
if !granted[r.ID] {
cand = append(cand, r)
}
}
// All collected → cycle, excluding the most recent to avoid a repeat.
if len(cand) == 0 {
last, _ := s.rewards.LastGrantedRewardID(ctx, userID)
for _, r := range pool {
if r.ID != last {
cand = append(cand, r)
}
}
if len(cand) == 0 {
cand = pool
}
}
pick := cand[rand.Intn(len(cand))]
if err := s.rewards.CreateGrant(ctx, userID, pick.ID, milestone); err != nil {
s.logger.Warn("sadhna: could not grant reward", zap.Error(err))
}
}
// nextRewardDate projects the UTC date the next gift lands if the streak
// continues unbroken — the date the streak count next reaches a multiple of
// rewardEveryDays. An unfinished today is assumed to be completed.
func nextRewardDate(today time.Time, current int, todayCompleted bool) *string {
next := (current/rewardEveryDays + 1) * rewardEveryDays // first multiple > current
days := next - current
if !todayCompleted {
// today isn't counted yet; completing it is the first of the
// remaining days, so it lands one day sooner.
days--
}
if days < 0 {
days = 0
}
d := today.AddDate(0, 0, days).Format(dayFmt)
return &d
}
// Rewards returns the user's collected gifts (newest first) and the size of
// the giftable catalog (for "N of M" framing on the client).
func (s *SadhnaService) Rewards(ctx context.Context, userID string) ([]model.SadhnaRewardGrant, int, error) {
grants, err := s.rewards.GrantsByUser(ctx, userID)
if err != nil {
return nil, 0, err
}
pool, err := s.rewards.ListGiftable(ctx)
if err != nil {
return nil, 0, err
}
return grants, len(pool), nil
}
// AckRewards clears the user's pending celebration(s) after the client has
// shown the confetti.
func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error {
return s.rewards.MarkCelebrated(ctx, userID)
}
// History returns the per-day sādhana state for every UTC calendar day in
// the inclusive range [from, to] — what the calendar needs to render an
// arbitrary week or month. Days with no activity (including future days)
// come back as all-false. The caller is expected to bound the range.
func (s *SadhnaService) History(ctx context.Context, userID string, from, to time.Time) ([]model.SadhnaDay, error) {
logs, err := s.logs.ListSince(ctx, userID, from)
if err != nil {
return nil, err
}
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from)
if err != nil {
return nil, err
}
chantByDay := make(map[string]bool, len(logs))
for _, l := range logs {
chantByDay[l.LogDate.UTC().Format(dayFmt)] = l.ChantDone
}
out := make([]model.SadhnaDay, 0, 42)
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
key := d.Format(dayFmt)
quote := postsByDay[key] > 0
out = append(out, model.SadhnaDay{
Date: key,
ChantDone: chantByDay[key],
QuoteDone: quote,
Completed: chantByDay[key] && quote,
})
}
return out, nil
}
// targetFor returns the user's daily rounds goal, falling back to the
// default when unset/out of range or when the user can't be loaded (the
// dashboard should still render).
func (s *SadhnaService) targetFor(ctx context.Context, userID string) int {
u, err := s.users.FindByID(ctx, userID)
if err != nil || u == nil || u.DailyChantingRounds == nil {
return defaultChantTarget
}
if r := *u.DailyChantingRounds; r >= 1 && r <= 1008 {
return r
}
return defaultChantTarget
}
// stageFor maps a growth score to the plant's visual stage. The client
// renders the matching artwork and interpolates within the stage by the
// raw growth value.
func stageFor(growth int) string {
switch {
case growth <= 0:
return "dead"
case growth <= 15:
return "sprout"
case growth <= 35:
return "seedling"
case growth <= 60:
return "sapling"
case growth <= 85:
return "young"
default:
return "mature"
}
}