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
+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"
}
}