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