From f710762b90e3f9f413ee8bae8d7a3593e55a65f6 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 20 Aug 2026 20:47:14 +0330 Subject: [PATCH] feat: expose complete Iranian reporting periods --- internal/analytics/periods.go | 203 +++++++++++++++++++++++++++++ internal/analytics/periods_test.go | 76 +++++++++++ internal/analytics/service.go | 7 +- 3 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 internal/analytics/periods.go create mode 100644 internal/analytics/periods_test.go diff --git a/internal/analytics/periods.go b/internal/analytics/periods.go new file mode 100644 index 0000000..c0ef2f1 --- /dev/null +++ b/internal/analytics/periods.go @@ -0,0 +1,203 @@ +package analytics + +import ( + "fmt" + "strings" + "time" +) + +var jalaliBreaks = [...]int{-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178} + +var jalaliMonthNames = [...]string{ + "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", + "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند", +} + +var persianSeasonNames = [...]string{"بهار", "تابستان", "پاییز", "زمستان"} + +type jalaliCalculation struct { + leap int + gy int + march int +} + +func jalaliCalendar(jy int) jalaliCalculation { + gy := jy + 621 + leapJ := -14 + jp := jalaliBreaks[0] + jump := 0 + for index := 1; index < len(jalaliBreaks); index++ { + jm := jalaliBreaks[index] + jump = jm - jp + if jy < jm { + break + } + leapJ += jump/33*8 + (jump%33)/4 + jp = jm + } + n := jy - jp + leapJ += n/33*8 + (n%33+3)/4 + if jump%33 == 4 && jump-n == 4 { + leapJ++ + } + leapG := gy/4 - ((gy/100+1)*3)/4 - 150 + march := 20 + leapJ - leapG + if jump-n < 6 { + n = n - jump + (jump+4)/33*33 + } + leap := ((n+1)%33 - 1) % 4 + if leap == -1 { + leap = 4 + } + return jalaliCalculation{leap: leap, gy: gy, march: march} +} + +func gregorianDayNumber(gy, gm, gd int) int { + d := ((gy+(gm-8)/6+100100)*1461)/4 + (153*((gm+9)%12)+2)/5 + gd - 34840408 + d = d - (((gy+100100+(gm-8)/6)/100)*3)/4 + 752 + return d +} + +func gregorianFromDayNumber(dayNumber int) (int, int, int) { + j := 4*dayNumber + 139361631 + j = j + (((4*dayNumber+183187720)/146097)*3/4)*4 - 3908 + i := ((j % 1461) / 4 * 5) + 308 + gd := (i%153)/5 + 1 + gm := (i/153)%12 + 1 + gy := j/1461 - 100100 + (8-gm)/6 + return gy, gm, gd +} + +func jalaliDayNumber(jy, jm, jd int) int { + calculation := jalaliCalendar(jy) + return gregorianDayNumber(calculation.gy, 3, calculation.march) + (jm-1)*31 - (jm/7)*(jm-7) + jd - 1 +} + +func jalaliToGregorian(jy, jm, jd int) (int, int, int) { + return gregorianFromDayNumber(jalaliDayNumber(jy, jm, jd)) +} + +func gregorianToJalali(gy, gm, gd int) (int, int, int) { + jy := gy - 621 + calculation := jalaliCalendar(jy) + dayNumber := gregorianDayNumber(gy, gm, gd) + firstFarvardin := gregorianDayNumber(gy, 3, calculation.march) + k := dayNumber - firstFarvardin + if k >= 0 { + if k <= 185 { + return jy, 1 + k/31, k%31 + 1 + } + k -= 186 + } else { + jy-- + k += 179 + if calculation.leap == 1 { + k++ + } + } + return jy, 7 + k/30, k%30 + 1 +} + +func jalaliDate(jy, jm, jd int, location *time.Location) time.Time { + gy, gm, gd := jalaliToGregorian(jy, jm, jd) + return time.Date(gy, time.Month(gm), gd, 0, 0, 0, 0, location) +} + +func previousJalaliMonth(jy, jm int) (int, int) { + jm-- + if jm == 0 { + return jy - 1, 12 + } + return jy, jm +} + +func nextJalaliMonth(jy, jm int) (int, int) { + jm++ + if jm == 13 { + return jy + 1, 1 + } + return jy, jm +} + +func latestCompletePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period { + localStart := datasetStart.In(location) + localMax := datasetMax.In(location) + datasetStartDay := time.Date(localStart.Year(), localStart.Month(), localStart.Day(), 0, 0, 0, 0, location) + dayStart := time.Date(localMax.Year(), localMax.Month(), localMax.Day(), 0, 0, 0, 0, location) + datasetEnd := dayStart.AddDate(0, 0, 1) + + weekEnd := datasetEnd + for weekEnd.Weekday() != time.Saturday { + weekEnd = weekEnd.AddDate(0, 0, -1) + } + weekStart := weekEnd.AddDate(0, 0, -7) + + jy, jm, _ := gregorianToJalali(localMax.Year(), int(localMax.Month()), localMax.Day()) + monthStart := jalaliDate(jy, jm, 1, location) + nextYear, nextMonth := nextJalaliMonth(jy, jm) + monthEnd := jalaliDate(nextYear, nextMonth, 1, location) + if monthEnd.After(datasetEnd) { + jy, jm = previousJalaliMonth(jy, jm) + monthStart = jalaliDate(jy, jm, 1, location) + nextYear, nextMonth = nextJalaliMonth(jy, jm) + monthEnd = jalaliDate(nextYear, nextMonth, 1, location) + } + + seasonStartMonth := ((jm-1)/3)*3 + 1 + seasonYear := jy + seasonStart := jalaliDate(seasonYear, seasonStartMonth, 1, location) + seasonEndYear, seasonEndMonth := seasonYear, seasonStartMonth+3 + if seasonEndMonth > 12 { + seasonEndYear++ + seasonEndMonth -= 12 + } + seasonEnd := jalaliDate(seasonEndYear, seasonEndMonth, 1, location) + if seasonEnd.After(datasetEnd) { + seasonStartMonth -= 3 + if seasonStartMonth < 1 { + seasonYear-- + seasonStartMonth += 12 + } + seasonStart = jalaliDate(seasonYear, seasonStartMonth, 1, location) + seasonEndYear, seasonEndMonth = seasonYear, seasonStartMonth+3 + if seasonEndMonth > 12 { + seasonEndYear++ + seasonEndMonth -= 12 + } + seasonEnd = jalaliDate(seasonEndYear, seasonEndMonth, 1, location) + } + + dayJY, dayJM, dayJD := gregorianToJalali(dayStart.Year(), int(dayStart.Month()), dayStart.Day()) + weekEndJY, weekEndJM, weekEndJD := gregorianToJalali(weekEnd.AddDate(0, 0, -1).Year(), int(weekEnd.AddDate(0, 0, -1).Month()), weekEnd.AddDate(0, 0, -1).Day()) + weekStartJY, weekStartJM, weekStartJD := gregorianToJalali(weekStart.Year(), int(weekStart.Month()), weekStart.Day()) + + periods := []Period{ + newPeriod("d-"+dayStart.Format(time.DateOnly), "DAY", dayStart, datasetEnd, + fmt.Sprintf("روز %s %s %s", persianNumber(dayJD), jalaliMonthNames[dayJM-1], persianNumber(dayJY)), false), + newPeriod("w-"+weekStart.Format(time.DateOnly), "WEEK", weekStart, weekEnd, + fmt.Sprintf("هفته %s %s تا %s %s", persianNumber(weekStartJD), jalaliMonthNames[weekStartJM-1], persianNumber(weekEndJD), jalaliMonthNames[weekEndJM-1]), false), + newPeriod(fmt.Sprintf("jm-%04d-%02d", jy, jm), "JALALI_MONTH", monthStart, monthEnd, + fmt.Sprintf("%s %s", jalaliMonthNames[jm-1], persianNumber(jy)), true), + newPeriod(fmt.Sprintf("ps-%04d-%02d", seasonYear, seasonStartMonth), "PERSIAN_SEASON", seasonStart, seasonEnd, + fmt.Sprintf("%s %s", persianSeasonNames[(seasonStartMonth-1)/3], persianNumber(seasonYear)), false), + } + for index := range periods { + periods[index].IsComplete = !periods[index].LocalStartAt.Before(datasetStartDay) + } + _ = weekEndJY + _ = weekStartJY + return periods +} + +func newPeriod(id, kind string, localStart, localEnd time.Time, label string, isDefault bool) Period { + return Period{ + PeriodID: id, PeriodType: kind, StartAt: localStart.UTC(), EndAt: localEnd.UTC(), + LocalStartAt: localStart, LocalEndAt: localEnd, Timezone: "Asia/Tehran", + JalaliLabelFA: label, IsComplete: true, IsDefault: isDefault, + } +} + +func persianNumber(value int) string { + replacer := strings.NewReplacer("0", "۰", "1", "۱", "2", "۲", "3", "۳", "4", "۴", "5", "۵", "6", "۶", "7", "۷", "8", "۸", "9", "۹") + return replacer.Replace(fmt.Sprint(value)) +} diff --git a/internal/analytics/periods_test.go b/internal/analytics/periods_test.go new file mode 100644 index 0000000..5a0504e --- /dev/null +++ b/internal/analytics/periods_test.go @@ -0,0 +1,76 @@ +package analytics + +import ( + "testing" + "time" +) + +func TestJalaliGregorianRoundTrip(t *testing.T) { + t.Parallel() + tests := []struct { + name string + jy, jm, jd int + gy, gm, gd int + }{ + {name: "Nowruz 1405", jy: 1405, jm: 1, jd: 1, gy: 2026, gm: 3, gd: 21}, + {name: "leap Esfand", jy: 1399, jm: 12, jd: 30, gy: 2021, gm: 3, gd: 20}, + {name: "Tir boundary", jy: 1405, jm: 4, jd: 1, gy: 2026, gm: 6, gd: 22}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gy, gm, gd := jalaliToGregorian(tt.jy, tt.jm, tt.jd) + if gy != tt.gy || gm != tt.gm || gd != tt.gd { + t.Fatalf("jalaliToGregorian(%d,%d,%d) = %04d-%02d-%02d, want %04d-%02d-%02d", tt.jy, tt.jm, tt.jd, gy, gm, gd, tt.gy, tt.gm, tt.gd) + } + jy, jm, jd := gregorianToJalali(tt.gy, tt.gm, tt.gd) + if jy != tt.jy || jm != tt.jm || jd != tt.jd { + t.Fatalf("gregorianToJalali(%04d-%02d-%02d) = %d-%02d-%02d, want %d-%02d-%02d", tt.gy, tt.gm, tt.gd, jy, jm, jd, tt.jy, tt.jm, tt.jd) + } + }) + } +} + +func TestLatestCompletePeriods(t *testing.T) { + t.Parallel() + location, err := time.LoadLocation("Asia/Tehran") + if err != nil { + t.Fatal(err) + } + datasetStart := time.Date(2026, time.January, 1, 0, 0, 0, 0, location) + datasetMax := time.Date(2026, time.June, 30, 23, 59, 0, 0, location) + + periods := latestCompletePeriods(datasetStart, datasetMax, location) + if len(periods) != 4 { + t.Fatalf("latestCompletePeriods() returned %d periods, want 4", len(periods)) + } + + want := []struct { + id, kind, start, end string + isDefault bool + }{ + {id: "d-2026-06-30", kind: "DAY", start: "2026-06-30", end: "2026-07-01"}, + {id: "w-2026-06-20", kind: "WEEK", start: "2026-06-20", end: "2026-06-27"}, + {id: "jm-1405-03", kind: "JALALI_MONTH", start: "2026-05-22", end: "2026-06-22", isDefault: true}, + {id: "ps-1405-01", kind: "PERSIAN_SEASON", start: "2026-03-21", end: "2026-06-22"}, + } + + for index, expected := range want { + period := periods[index] + if period.PeriodID != expected.id || period.PeriodType != expected.kind { + t.Errorf("period[%d] identity = %s/%s, want %s/%s", index, period.PeriodID, period.PeriodType, expected.id, expected.kind) + } + if got := period.LocalStartAt.Format(time.DateOnly); got != expected.start { + t.Errorf("period[%d] start = %s, want %s", index, got, expected.start) + } + if got := period.LocalEndAt.Format(time.DateOnly); got != expected.end { + t.Errorf("period[%d] end = %s, want %s", index, got, expected.end) + } + if period.IsDefault != expected.isDefault { + t.Errorf("period[%d] IsDefault = %v, want %v", index, period.IsDefault, expected.isDefault) + } + if period.StartAt.Location() != time.UTC || period.EndAt.Location() != time.UTC { + t.Errorf("period[%d] UTC boundaries do not use UTC", index) + } + } +} diff --git a/internal/analytics/service.go b/internal/analytics/service.go index 81fe875..0dd6760 100644 --- a/internal/analytics/service.go +++ b/internal/analytics/service.go @@ -51,7 +51,7 @@ func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, runs: map[st func (s *Service) Periods(ctx context.Context) ([]Period, error) { var start, end time.Time - err := s.pool.QueryRow(ctx, `SELECT date_trunc('day',min(created_at)),date_trunc('day',max(created_at))+interval '1 day' FROM payment_sessions`).Scan(&start, &end) + err := s.pool.QueryRow(ctx, `SELECT min(created_at),max(created_at) FROM payment_sessions`).Scan(&start, &end) if err != nil { return nil, fmt.Errorf("loading dataset period: %w", err) } @@ -59,10 +59,7 @@ func (s *Service) Periods(ctx context.Context) ([]Period, error) { if err != nil { return nil, fmt.Errorf("loading tehran timezone: %w", err) } - period := Period{PeriodID: "dataset-all", PeriodType: "DAY", StartAt: start.UTC(), EndAt: end.UTC(), - LocalStartAt: start.In(location), LocalEndAt: end.In(location), Timezone: "Asia/Tehran", - JalaliLabelFA: "کل داده‌های واردشده", IsComplete: true, IsDefault: true} - return []Period{period}, nil + return latestCompletePeriods(start, end, location), nil } func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (Run, error) {