package coach import ( "errors" "math" "time" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics" ) type MetricPolicy struct { Direction string Kind string } var metricAllowlist = map[string]MetricPolicy{ "success_rate": {Direction: "INCREASE", Kind: "RATE"}, "paid_count": {Direction: "INCREASE", Kind: "COUNT"}, "paid_amount": {Direction: "INCREASE", Kind: "AMOUNT"}, "avg_latency": {Direction: "DECREASE", Kind: "LATENCY"}, "fee_amount": {Direction: "DECREASE", Kind: "FEE"}, } func MetricTargetReached(direction string, current, target float64) bool { return (direction == "INCREASE" && current >= target) || (direction == "DECREASE" && current <= target) } func CalculateTarget(metricID, level string, baseline float64, sampleSize int64) (float64, error) { policy, ok := metricAllowlist[metricID] if !ok || sampleSize < 100 || baseline < 0 { return 0, errors.New("growth coach: metric target unavailable") } index := map[string]int{"SMALL": 0, "MEDIUM": 1, "STRETCH": 2}[level] if _, ok := map[string]bool{"SMALL": true, "MEDIUM": true, "STRETCH": true}[level]; !ok { return 0, errors.New("growth coach: invalid action level") } var target float64 if policy.Kind == "RATE" { target = baseline + []float64{2, 5, 10}[index] if target > 100 { target = 100 } } else { change := []float64{.05, .10, .20}[index] if policy.Direction == "DECREASE" { target = baseline * (1 - change) } else { target = baseline * (1 + change) } } target = math.Round(target*1_000_000) / 1_000_000 if target == baseline { return 0, errors.New("growth coach: metric target has no actionable distance") } return target, nil } func GoalProgress(direction string, baseline, target, current float64) (float64, bool) { distance := target - baseline if distance == 0 || (direction != "INCREASE" && direction != "DECREASE") { return 0, false } progress := (current - baseline) / distance * 100 if progress < 0 { progress = 0 } if progress > 100 { progress = 100 } return math.Round(progress*100) / 100, true } func ValidTransition(actionType, current, transition string) (string, error) { if transition == "ABANDON" && (current == "NOT_STARTED" || current == "IN_PROGRESS") { return "ABANDONED", nil } if actionType == "METRIC" { return "", ErrInvalidTransition } if transition == "START" && current == "NOT_STARTED" { return "IN_PROGRESS", nil } if transition == "COMPLETE" && (current == "NOT_STARTED" || current == "IN_PROGRESS") { return "COMPLETED", nil } return "", ErrInvalidTransition } func PeriodBounds(cadence string, now time.Time) (time.Time, time.Time) { location, err := time.LoadLocation("Asia/Tehran") if err != nil { location = time.FixedZone("Asia/Tehran", 3*60*60+30*60) } local := now.In(location) day := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) switch cadence { case "WEEKLY": delta := (int(day.Weekday()) - int(time.Saturday) + 7) % 7 start := day.AddDate(0, 0, -delta) return start, start.AddDate(0, 0, 6) case "MONTHLY": return analytics.JalaliMonthBounds(day, location) default: return day, day } } func ManualRefreshAllowed(cadence string, weeklyUses int) bool { switch cadence { case "WEEKLY": return weeklyUses < 2 case "MONTHLY": return true default: return false } } func NextTehranMidnight(now time.Time) time.Time { location, err := time.LoadLocation("Asia/Tehran") if err != nil { location = time.FixedZone("Asia/Tehran", 3*60*60+30*60) } local := now.In(location) return time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, location) } type DifficultyHistory struct { Current int MonthlyCompletedDaysEarly int RecentWeeklyRates []float64 RecentPeriodRates []float64 HardRejections int } func ProposeDifficulty(history DifficultyHistory) (int, string, bool) { if history.Current < 1 || history.Current > 5 { return 0, "", false } if history.Current < 5 && (history.MonthlyCompletedDaysEarly >= 7 || lastTwoAtLeast(history.RecentWeeklyRates, .8)) { return history.Current + 1, "اهداف اخیر زودتر یا با نرخ انجام بالا تکمیل شده‌اند.", true } if history.Current > 1 && (lastTwoBelow(history.RecentPeriodRates, .4) || history.HardRejections >= 2) { return history.Current - 1, "دو دوره اخیر دشوار بوده یا چند پیشنهاد به‌دلیل سختی رد شده است.", true } return 0, "", false } func lastTwoAtLeast(values []float64, threshold float64) bool { return len(values) >= 2 && values[len(values)-1] >= threshold && values[len(values)-2] >= threshold } func lastTwoBelow(values []float64, threshold float64) bool { return len(values) >= 2 && values[len(values)-1] < threshold && values[len(values)-2] < threshold }