453 lines
14 KiB
Go
453 lines
14 KiB
Go
// 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.
|
||
func (s *SadhnaService) resolveTZ(ctx context.Context, userID string) (string, *time.Location) {
|
||
tz, _ := s.logs.UserTimezone(ctx, userID)
|
||
if tz == "" {
|
||
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 {
|
||
return nil, err
|
||
}
|
||
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since, tz)
|
||
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)
|
||
// 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 {
|
||
return nil, err
|
||
}
|
||
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from, tz)
|
||
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"
|
||
}
|
||
}
|