package growth import ( "slices" "strings" "time" ) func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) EventEffect { effect := EventEffect{EvidenceID: "event:" + event.ID, EventID: event.ID} eventDates := catalogDates(append(slices.Clone(catalog), event)) byDate := pointsByDate(history) eventDays := daysInclusive(event.StartDate, event.EndDate) var baselineSessions, baselinePaid, baselineAmount int64 actualComplete := true for _, eventDay := range eventDays { point, ok := byDate[dayKey(eventDay)] if !ok { actualComplete = false break } effect.ActualSessionCount += point.SessionCount effect.ActualPaidSessions += point.PaidSessionCount effect.ActualAmountRial += point.PaidAmountRial } if !actualComplete { effect.SuppressionReason = SuppressionIncompleteEventWindow return effect } for week := 1; week <= 8; week++ { var windowSessions, windowPaid, windowAmount int64 complete := true for _, eventDay := range eventDays { candidate := eventDay.AddDate(0, 0, -7*week) if eventDates[dayKey(candidate)] { complete = false break } point, ok := byDate[dayKey(candidate)] if !ok { complete = false break } windowSessions += point.SessionCount windowPaid += point.PaidSessionCount windowAmount += point.PaidAmountRial } if !complete { continue } effect.ObservationCount++ baselineSessions += windowSessions baselinePaid += windowPaid baselineAmount += windowAmount } if effect.ObservationCount < MinBaselineObservations { effect.SuppressionReason = SuppressionInsufficientObservations return effect } effect.BaselineSessionCount = baselineSessions / int64(effect.ObservationCount) effect.BaselinePaidSessions = baselinePaid / int64(effect.ObservationCount) effect.BaselineAmountRial = baselineAmount / int64(effect.ObservationCount) if baselineSessions < MinBaselineSessions { effect.SuppressionReason = SuppressionInsufficientBaselineSessions return effect } effect.Eligible = true effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount, effect.BaselineSessionCount) effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions, effect.BaselinePaidSessions) effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial, effect.BaselineAmountRial) effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS return effect } func MatchScoreBPS(historical, upcoming Event) (int64, string) { if historical.Family != "" && strings.EqualFold(strings.TrimSpace(historical.Family), strings.TrimSpace(upcoming.Family)) { return 10_000, "FAMILY" } a := normalizedSet(historical.Tags) b := normalizedSet(upcoming.Tags) if len(a) == 0 || len(b) == 0 { return 0, "TAGS" } intersection := 0 union := make(map[string]struct{}, len(a)+len(b)) for tag := range a { union[tag] = struct{}{} if _, ok := b[tag]; ok { intersection++ } } for tag := range b { union[tag] = struct{}{} } return int64(intersection * 10_000 / len(union)), "TAGS" } func MatchUpcomingEvents(historical Event, upcoming []Event) []EventMatch { matches := make([]EventMatch, 0, len(upcoming)) for _, candidate := range upcoming { score, basis := MatchScoreBPS(historical, candidate) if score >= MinSimilarityBPS { matches = append(matches, EventMatch{Event: candidate, ScoreBPS: score, MatchBasis: basis}) } } slices.SortFunc(matches, func(a, b EventMatch) int { if a.ScoreBPS != b.ScoreBPS { return int(b.ScoreBPS - a.ScoreBPS) } return strings.Compare(a.Event.ID, b.Event.ID) }) return matches } func pointsByDate(points []DailyPoint) map[string]DailyPoint { result := make(map[string]DailyPoint, len(points)) for _, point := range points { result[dayKey(point.Date)] = point } return result } func catalogDates(events []Event) map[string]bool { result := make(map[string]bool) for _, event := range events { for _, day := range daysInclusive(event.StartDate, event.EndDate) { result[dayKey(day)] = true } } return result } func daysInclusive(start, end time.Time) []time.Time { if end.Before(start) { return nil } days := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1) for day := normalizeDate(start); !day.After(normalizeDate(end)); day = day.AddDate(0, 0, 1) { days = append(days, day) } return days } func normalizedSet(values []string) map[string]struct{} { result := make(map[string]struct{}, len(values)) for _, value := range values { value = strings.ToLower(strings.TrimSpace(value)) if value != "" { result[value] = struct{}{} } } return result } func normalizeDate(value time.Time) time.Time { year, month, day := value.Date() return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) } func dayKey(value time.Time) string { return normalizeDate(value).Format(time.DateOnly) } func dateBefore(a, b time.Time) bool { return dayKey(a) < dayKey(b) } func dateAfter(a, b time.Time) bool { return dayKey(a) > dayKey(b) } func changeBPS(actual, baseline int64) int64 { if baseline <= 0 { return 0 } return (actual - baseline) * 10_000 / baseline }