Files
spsays-bff/internal/service/sadhna_service.go
2026-06-11 01:11:10 +05:30

476 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
// fallbackTZ is used when the user has no stored timezone — the sādhana day
// boundary (and thus the nightly chant reset) defaults to India time.
const fallbackTZ = "Asia/Kolkata"
// resolveTZ returns the user's IANA timezone name + loaded location, falling
// back to Asia/Kolkata (then UTC) so the day boundary is always defined.
//
// The users.timezone column defaults to 'UTC' (migration 0028), so a bare
// "UTC" means the browser never reported a real zone — i.e. we don't actually
// know the user's local time. Per product intent we treat that as unknown and
// fall back to India time, rather than literally bucketing their day at UTC
// midnight (which mismatches the browser-local calendar on the client). A
// genuine UTC user re-reports their real zone on next load.
func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) {
tz, _ := s.logs.UserTimezone(ctx, userID)
// Canonicalize legacy aliases (e.g. stored "Asia/Calcutta") — the name
// is fed into SQL AT TIME ZONE, which rejects aliases on a Postgres
// without the tzdata backward links. Covers rows written before
// SetTimezone started canonicalizing.
tz = canonicalTZ(tz)
if tz == "" || tz == "UTC" {
tz = fallbackTZ
}
loc, err := time.LoadLocation(tz)
if err != nil {
tz = fallbackTZ
if loc, err = time.LoadLocation(fallbackTZ); err != nil {
return "UTC", time.UTC
}
}
return tz, loc
}
// todayIn returns the current calendar date in loc, anchored at UTC midnight
// so it round-trips cleanly through Postgres DATE columns and dayFmt. This is
// the anchor every day-bucket comparison uses — the day rolls over at the
// user's local midnight.
func todayIn(loc *time.Location) time.Time {
now := time.Now().In(loc)
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
// Bucket against the user's local "today" so progress resets at their
// local midnight (fallback Asia/Kolkata), not UTC midnight.
_, loc := s.resolveTZ(ctx, userID)
if _, err := s.logs.UpsertToday(ctx, userID, todayIn(loc), 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)
tz, loc := s.resolveTZ(ctx, userID)
today := todayIn(loc)
since := today.AddDate(0, 0, -(streakWindowDays - 1))
logs, err := s.logs.ListSince(ctx, userID, since)
if err != nil {
s.logger.Error("sadhna dashboard: list logs failed",
zap.String("user", userID), zap.Error(err))
return nil, err
}
// The quote half is non-critical: if it fails (e.g. an unrecognized
// timezone in the AT TIME ZONE bucketing), log it and degrade to "no
// quotes" rather than 500-ing the whole dashboard.
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since, tz)
if err != nil {
s.logger.Error("sadhna dashboard: post counts failed",
zap.String("user", userID), zap.String("tz", tz), zap.Error(err))
postsByDay = map[string]int{}
}
// 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)
// Only project a next-gift date when a streak is actually in
// progress. With no live streak (current == 0) there's nothing to
// "continue", so the calendar shows no gift marker until the devotee
// completes a day (full rounds AND a quote).
if current > 0 {
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) {
tz, _ := s.resolveTZ(ctx, userID)
logs, err := s.logs.ListSince(ctx, userID, from)
if err != nil {
s.logger.Error("sadhna history: list logs failed",
zap.String("user", userID), zap.Error(err))
return nil, err
}
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from, tz)
if err != nil {
s.logger.Error("sadhna history: post counts failed",
zap.String("user", userID), zap.String("tz", tz), zap.Error(err))
postsByDay = map[string]int{}
}
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"
}
}