From 6c61e97bba4ad6d6f192f28548b641ede8b2dbc6 Mon Sep 17 00:00:00 2001 From: Krispire Admin Date: Tue, 9 Jun 2026 00:39:03 +0530 Subject: [PATCH] fix: sync sadhna calendar with local timezone --- internal/repository/post_repo.go | 20 ++++++++------ internal/repository/sadhna_repo.go | 14 ++++++++++ internal/service/sadhna_service.go | 44 ++++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 16 deletions(-) diff --git a/internal/repository/post_repo.go b/internal/repository/post_repo.go index e42a43b..2cd1bad 100644 --- a/internal/repository/post_repo.go +++ b/internal/repository/post_repo.go @@ -243,18 +243,22 @@ func (r *PostRepo) PostedDates(ctx context.Context, userID string, since time.Ti return out, rows.Err() } -// PostCountsByDate returns, for each UTC calendar day on or after `since`, -// how many posts the user created that day. Keys are "YYYY-MM-DD"; days -// with no posts are absent (so a missing key reads as zero). The sādhana -// vine uses this both for the "posted a quote today" discipline and to -// annotate each day's fold with the number of quotes shared. -func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time) (map[string]int, error) { +// PostCountsByDate returns, for each calendar day on or after `since`, how +// many posts the user created that day — bucketed in the given IANA timezone +// (`tz`) so the day boundary matches the user's local midnight. Keys are +// "YYYY-MM-DD"; days with no posts are absent (so a missing key reads as +// zero). The sādhana page uses this for the "posted a quote today" discipline +// and to annotate each day's fold with the number of quotes shared. +func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time, tz string) (map[string]int, error) { + if tz == "" { + tz = "UTC" + } rows, err := r.pg.Pool.Query(ctx, ` - SELECT (created_at AT TIME ZONE 'UTC')::date AS d, count(*) AS n + SELECT (created_at AT TIME ZONE $3)::date AS d, count(*) AS n FROM posts WHERE user_id = $1 AND created_at >= $2 GROUP BY d`, - userID, since, + userID, since, tz, ) if err != nil { return nil, fmt.Errorf("post counts by date: %w", err) diff --git a/internal/repository/sadhna_repo.go b/internal/repository/sadhna_repo.go index 952686c..27debb9 100644 --- a/internal/repository/sadhna_repo.go +++ b/internal/repository/sadhna_repo.go @@ -15,6 +15,20 @@ type SadhnaRepo struct{ pg *Postgres } func NewSadhnaRepo(pg *Postgres) *SadhnaRepo { return &SadhnaRepo{pg: pg} } +// UserTimezone returns the user's stored IANA timezone, or "" if unset — the +// caller falls back to a default. Kept here so the sādhana day boundary can +// be the user's local midnight. +func (r *SadhnaRepo) UserTimezone(ctx context.Context, userID string) (string, error) { + var tz string + err := r.pg.Pool.QueryRow(ctx, + `SELECT COALESCE(NULLIF(timezone, ''), '') FROM users WHERE id = $1`, userID, + ).Scan(&tz) + if err != nil { + return "", err + } + return tz, nil +} + // UpsertToday records (or updates) the given day's chanting progress for // the user. The caller passes the UTC calendar date explicitly (rather // than relying on the DB's CURRENT_DATE) so day bucketing stays identical diff --git a/internal/service/sadhna_service.go b/internal/service/sadhna_service.go index ee36d5a..428e4b5 100644 --- a/internal/service/sadhna_service.go +++ b/internal/service/sadhna_service.go @@ -79,10 +79,33 @@ func NewSadhnaService( 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() +// 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) } @@ -99,7 +122,10 @@ func (s *SadhnaService) SaveChant(ctx context.Context, userID string, rounds int target := s.targetFor(ctx, userID) chantDone := rounds >= target - if _, err := s.logs.UpsertToday(ctx, userID, utcToday(), rounds, chantDone); err != nil { + // 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) @@ -109,14 +135,15 @@ func (s *SadhnaService) SaveChant(ctx context.Context, userID string, rounds int // 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() + 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) + postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since, tz) if err != nil { return nil, err } @@ -361,11 +388,12 @@ func (s *SadhnaService) AckRewards(ctx context.Context, userID string) error { // 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) + postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from, tz) if err != nil { return nil, err }