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 } // JalaliMonthBounds returns the Tehran-local start and inclusive end of the // Jalali month containing at. It is shared by deterministic business rules. func JalaliMonthBounds(at time.Time, location *time.Location) (time.Time, time.Time) { local := at.In(location) year, month, _ := gregorianToJalali(local.Year(), int(local.Month()), local.Day()) start := jalaliDate(year, month, 1, location) nextYear, nextMonth := nextJalaliMonth(year, month) return start, jalaliDate(nextYear, nextMonth, 1, location).AddDate(0, 0, -1) } func completePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period { if datasetMax.Before(datasetStart) { return nil } localStart := datasetStart.In(location) localMax := datasetMax.In(location) datasetStartDay := time.Date(localStart.Year(), localStart.Month(), localStart.Day(), 0, 0, 0, 0, location) datasetEnd := time.Date(localMax.Year(), localMax.Month(), localMax.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1) periods := make([]Period, 0) for dayStart := datasetEnd.AddDate(0, 0, -1); !dayStart.Before(datasetStartDay); dayStart = dayStart.AddDate(0, 0, -1) { jy, jm, jd := gregorianToJalali(dayStart.Year(), int(dayStart.Month()), dayStart.Day()) periods = append(periods, newPeriod( "d-"+dayStart.Format(time.DateOnly), "DAY", dayStart, dayStart.AddDate(0, 0, 1), fmt.Sprintf("روز %s %s %s", persianNumber(jd), jalaliMonthNames[jm-1], persianNumber(jy)), false, )) } weekEnd := datasetEnd for weekEnd.Weekday() != time.Saturday { weekEnd = weekEnd.AddDate(0, 0, -1) } for weekStart := weekEnd.AddDate(0, 0, -7); !weekStart.Before(datasetStartDay); weekStart = weekStart.AddDate(0, 0, -7) { weekEnd = weekStart.AddDate(0, 0, 7) _, weekStartJM, weekStartJD := gregorianToJalali(weekStart.Year(), int(weekStart.Month()), weekStart.Day()) weekLastDay := weekEnd.AddDate(0, 0, -1) weekEndJY, weekEndJM, weekEndJD := gregorianToJalali(weekLastDay.Year(), int(weekLastDay.Month()), weekLastDay.Day()) periods = append(periods, newPeriod( "w-"+weekStart.Format(time.DateOnly), "WEEK", weekStart, weekEnd, fmt.Sprintf( "هفته %s %s تا %s %s %s", persianNumber(weekStartJD), jalaliMonthNames[weekStartJM-1], persianNumber(weekEndJD), jalaliMonthNames[weekEndJM-1], persianNumber(weekEndJY), ), false, )) } latestDay := datasetEnd.AddDate(0, 0, -1) latestJY, latestJM, _ := gregorianToJalali(latestDay.Year(), int(latestDay.Month()), latestDay.Day()) monthYear, month := latestJY, latestJM monthStart := jalaliDate(monthYear, month, 1, location) nextYear, nextMonth := nextJalaliMonth(monthYear, month) monthEnd := jalaliDate(nextYear, nextMonth, 1, location) if monthEnd.After(datasetEnd) { monthYear, month = previousJalaliMonth(monthYear, month) } for isDefault := true; ; isDefault = false { monthStart = jalaliDate(monthYear, month, 1, location) nextYear, nextMonth = nextJalaliMonth(monthYear, month) monthEnd = jalaliDate(nextYear, nextMonth, 1, location) if monthStart.Before(datasetStartDay) { break } periods = append(periods, newPeriod( fmt.Sprintf("jm-%04d-%02d", monthYear, month), "JALALI_MONTH", monthStart, monthEnd, fmt.Sprintf("%s %s", jalaliMonthNames[month-1], persianNumber(monthYear)), isDefault, )) monthYear, month = previousJalaliMonth(monthYear, month) } seasonYear := latestJY seasonStartMonth := ((latestJM - 1) / 3 * 3) + 1 seasonStart, seasonEnd := seasonBoundaries(seasonYear, seasonStartMonth, location) if seasonEnd.After(datasetEnd) { seasonYear, seasonStartMonth = previousPersianSeason(seasonYear, seasonStartMonth) } for { seasonStart, seasonEnd = seasonBoundaries(seasonYear, seasonStartMonth, location) if seasonStart.Before(datasetStartDay) { break } periods = append(periods, newPeriod( fmt.Sprintf("ps-%04d-%02d", seasonYear, seasonStartMonth), "PERSIAN_SEASON", seasonStart, seasonEnd, fmt.Sprintf("%s %s", persianSeasonNames[(seasonStartMonth-1)/3], persianNumber(seasonYear)), false, )) seasonYear, seasonStartMonth = previousPersianSeason(seasonYear, seasonStartMonth) } return periods } func seasonBoundaries(year, startMonth int, location *time.Location) (time.Time, time.Time) { endYear, endMonth := year, startMonth+3 if endMonth > 12 { endYear++ endMonth -= 12 } return jalaliDate(year, startMonth, 1, location), jalaliDate(endYear, endMonth, 1, location) } func previousPersianSeason(year, startMonth int) (int, int) { startMonth -= 3 if startMonth < 1 { return year - 1, startMonth + 12 } return year, startMonth } // PrecedingPeriodLabel returns the exact Persian label for the complete period // immediately before start. The label follows the same vocabulary as Periods. func PrecedingPeriodLabel(periodType string, start time.Time) (string, bool) { location, err := time.LoadLocation("Asia/Tehran") if err != nil { return "", false } localStart := start.In(location) switch periodType { case "DAY": day := localStart.AddDate(0, 0, -1) year, month, dayOfMonth := gregorianToJalali(day.Year(), int(day.Month()), day.Day()) return fmt.Sprintf("روز %s %s %s", persianNumber(dayOfMonth), jalaliMonthNames[month-1], persianNumber(year)), true case "WEEK": weekStart := localStart.AddDate(0, 0, -7) weekLastDay := localStart.AddDate(0, 0, -1) _, startMonth, startDay := gregorianToJalali(weekStart.Year(), int(weekStart.Month()), weekStart.Day()) endYear, endMonth, endDay := gregorianToJalali(weekLastDay.Year(), int(weekLastDay.Month()), weekLastDay.Day()) return fmt.Sprintf("هفته %s %s تا %s %s %s", persianNumber(startDay), jalaliMonthNames[startMonth-1], persianNumber(endDay), jalaliMonthNames[endMonth-1], persianNumber(endYear)), true case "JALALI_MONTH": year, month, _ := gregorianToJalali(localStart.Year(), int(localStart.Month()), localStart.Day()) year, month = previousJalaliMonth(year, month) return fmt.Sprintf("%s %s", jalaliMonthNames[month-1], persianNumber(year)), true case "PERSIAN_SEASON": year, month, _ := gregorianToJalali(localStart.Year(), int(localStart.Month()), localStart.Day()) year, month = previousPersianSeason(year, ((month-1)/3*3)+1) return fmt.Sprintf("%s %s", persianSeasonNames[(month-1)/3], persianNumber(year)), true default: return "", false } } 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)) }