fix: sync sadhna calendar with local timezone
This commit is contained in:
@@ -243,18 +243,22 @@ func (r *PostRepo) PostedDates(ctx context.Context, userID string, since time.Ti
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostCountsByDate returns, for each UTC calendar day on or after `since`,
|
// PostCountsByDate returns, for each calendar day on or after `since`, how
|
||||||
// how many posts the user created that day. Keys are "YYYY-MM-DD"; days
|
// many posts the user created that day — bucketed in the given IANA timezone
|
||||||
// with no posts are absent (so a missing key reads as zero). The sādhana
|
// (`tz`) so the day boundary matches the user's local midnight. Keys are
|
||||||
// vine uses this both for the "posted a quote today" discipline and to
|
// "YYYY-MM-DD"; days with no posts are absent (so a missing key reads as
|
||||||
// annotate each day's fold with the number of quotes shared.
|
// zero). The sādhana page uses this for the "posted a quote today" discipline
|
||||||
func (r *PostRepo) PostCountsByDate(ctx context.Context, userID string, since time.Time) (map[string]int, error) {
|
// 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, `
|
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
|
FROM posts
|
||||||
WHERE user_id = $1 AND created_at >= $2
|
WHERE user_id = $1 AND created_at >= $2
|
||||||
GROUP BY d`,
|
GROUP BY d`,
|
||||||
userID, since,
|
userID, since, tz,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("post counts by date: %w", err)
|
return nil, fmt.Errorf("post counts by date: %w", err)
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ type SadhnaRepo struct{ pg *Postgres }
|
|||||||
|
|
||||||
func NewSadhnaRepo(pg *Postgres) *SadhnaRepo { return &SadhnaRepo{pg: pg} }
|
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
|
// UpsertToday records (or updates) the given day's chanting progress for
|
||||||
// the user. The caller passes the UTC calendar date explicitly (rather
|
// the user. The caller passes the UTC calendar date explicitly (rather
|
||||||
// than relying on the DB's CURRENT_DATE) so day bucketing stays identical
|
// than relying on the DB's CURRENT_DATE) so day bucketing stays identical
|
||||||
|
|||||||
@@ -79,10 +79,33 @@ func NewSadhnaService(
|
|||||||
|
|
||||||
const dayFmt = "2006-01-02"
|
const dayFmt = "2006-01-02"
|
||||||
|
|
||||||
// utcToday returns today's UTC calendar date truncated to midnight, the
|
// fallbackTZ is used when the user has no stored timezone — the sādhana day
|
||||||
// anchor every day-bucket comparison uses.
|
// boundary (and thus the nightly chant reset) defaults to India time.
|
||||||
func utcToday() time.Time {
|
const fallbackTZ = "Asia/Kolkata"
|
||||||
now := time.Now().UTC()
|
|
||||||
|
// 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)
|
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)
|
target := s.targetFor(ctx, userID)
|
||||||
chantDone := rounds >= target
|
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 nil, err
|
||||||
}
|
}
|
||||||
return s.Dashboard(ctx, userID)
|
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.
|
// logs and quote posts over the trailing window.
|
||||||
func (s *SadhnaService) Dashboard(ctx context.Context, userID string) (*model.SadhnaDashboard, error) {
|
func (s *SadhnaService) Dashboard(ctx context.Context, userID string) (*model.SadhnaDashboard, error) {
|
||||||
target := s.targetFor(ctx, userID)
|
target := s.targetFor(ctx, userID)
|
||||||
today := utcToday()
|
tz, loc := s.resolveTZ(ctx, userID)
|
||||||
|
today := todayIn(loc)
|
||||||
since := today.AddDate(0, 0, -(streakWindowDays - 1))
|
since := today.AddDate(0, 0, -(streakWindowDays - 1))
|
||||||
|
|
||||||
logs, err := s.logs.ListSince(ctx, userID, since)
|
logs, err := s.logs.ListSince(ctx, userID, since)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since)
|
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, since, tz)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
// arbitrary week or month. Days with no activity (including future days)
|
||||||
// come back as all-false. The caller is expected to bound the range.
|
// 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) {
|
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)
|
logs, err := s.logs.ListSince(ctx, userID, from)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from)
|
postsByDay, err := s.posts.PostCountsByDate(ctx, userID, from, tz)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user