feat: add web push and sadhna tracker
This commit is contained in:
@@ -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>`))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user