fix: anchor growth forecasts to real Tehran date
This commit is contained in:
137
internal/eventcatalog/catalog.go
Normal file
137
internal/eventcatalog/catalog.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package eventcatalog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EventType string
|
||||
type Tone string
|
||||
type CommercialPosture string
|
||||
type Recurrence string
|
||||
|
||||
const (
|
||||
TypePublicHoliday EventType = "PUBLIC_HOLIDAY"
|
||||
TypeReligious EventType = "RELIGIOUS"
|
||||
TypeNational EventType = "NATIONAL"
|
||||
TypeCultural EventType = "CULTURAL"
|
||||
TypeCommercial EventType = "COMMERCIAL"
|
||||
TypeSeasonal EventType = "SEASONAL"
|
||||
TypeEducation EventType = "EDUCATION"
|
||||
TypeEconomic EventType = "ECONOMIC"
|
||||
TypeEmergency EventType = "EMERGENCY_CLOSURE"
|
||||
|
||||
ToneCelebratory Tone = "CELEBRATORY"
|
||||
ToneJoyful Tone = "JOYFUL"
|
||||
ToneNeutral Tone = "NEUTRAL"
|
||||
ToneSolemn Tone = "SOLEMN"
|
||||
ToneMourning Tone = "MOURNING"
|
||||
ToneMemorial Tone = "MEMORIAL"
|
||||
|
||||
PosturePromote CommercialPosture = "PROMOTE"
|
||||
PostureCaution CommercialPosture = "CAUTION"
|
||||
PostureNoPromotion CommercialPosture = "NO_PROMOTION"
|
||||
|
||||
RecurrenceBlackFriday Recurrence = "BLACK_FRIDAY"
|
||||
RecurrenceCyberMonday Recurrence = "CYBER_MONDAY"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
TitleFA string `json:"titleFa"`
|
||||
TitleEN string `json:"titleEn,omitempty"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Recurrence Recurrence `json:"recurrence,omitempty"`
|
||||
Type EventType `json:"eventType"`
|
||||
Tone Tone `json:"tone"`
|
||||
CommercialPosture CommercialPosture `json:"commercialPosture"`
|
||||
OfficialHoliday bool `json:"officialHoliday"`
|
||||
SourceURI string `json:"sourceUri"`
|
||||
SourceVersion string `json:"sourceVersion"`
|
||||
SourceChecksum string `json:"sourceChecksum,omitempty"`
|
||||
InclusionRationale string `json:"inclusionRationale"`
|
||||
GeographicScope string `json:"geographicScope"`
|
||||
MerchantCategories []string `json:"merchantCategories,omitempty"`
|
||||
BehavioralChannels []string `json:"behavioralChannels,omitempty"`
|
||||
LeadDays int `json:"leadDays"`
|
||||
LagDays int `json:"lagDays"`
|
||||
RelevanceBPS int `json:"relevanceBps"`
|
||||
ConfidenceBPS int `json:"confidenceBps"`
|
||||
}
|
||||
|
||||
func ResolveRecurrence(rule Recurrence, year int, location *time.Location) (time.Time, error) {
|
||||
if year < 1900 || year > 2200 {
|
||||
return time.Time{}, errors.New("event catalog: recurrence year out of range")
|
||||
}
|
||||
thanksgiving := nthWeekday(year, time.November, time.Thursday, 4, location)
|
||||
switch rule {
|
||||
case RecurrenceBlackFriday:
|
||||
return thanksgiving.AddDate(0, 0, 1), nil
|
||||
case RecurrenceCyberMonday:
|
||||
return thanksgiving.AddDate(0, 0, 4), nil
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf("event catalog: unsupported recurrence %q", rule)
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateEvents(events []Event) error {
|
||||
seen := make(map[string]struct{}, len(events))
|
||||
for _, event := range events {
|
||||
if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.SourceURI) == "" || strings.TrimSpace(event.SourceVersion) == "" || strings.TrimSpace(event.InclusionRationale) == "" || strings.TrimSpace(event.GeographicScope) == "" {
|
||||
return fmt.Errorf("event catalog: event %q is incomplete", event.ID)
|
||||
}
|
||||
if _, exists := seen[event.ID]; exists {
|
||||
return fmt.Errorf("event catalog: duplicate event %q", event.ID)
|
||||
}
|
||||
seen[event.ID] = struct{}{}
|
||||
start, err := time.Parse(time.DateOnly, event.StartDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event catalog: event %q start date: %w", event.ID, err)
|
||||
}
|
||||
end, err := time.Parse(time.DateOnly, event.EndDate)
|
||||
if err != nil || end.Before(start) {
|
||||
return fmt.Errorf("event catalog: event %q has invalid end date", event.ID)
|
||||
}
|
||||
if !validType(event.Type) || !validTone(event.Tone) || !validPosture(event.CommercialPosture) {
|
||||
return fmt.Errorf("event catalog: event %q has invalid classification", event.ID)
|
||||
}
|
||||
if (event.Tone == ToneMourning || event.Tone == ToneMemorial) && event.CommercialPosture == PosturePromote {
|
||||
return fmt.Errorf("event catalog: event %q cannot promote during mourning or memorial", event.ID)
|
||||
}
|
||||
if event.LeadDays < 0 || event.LagDays < 0 || event.RelevanceBPS < 0 || event.RelevanceBPS > 10_000 || event.ConfidenceBPS < 0 || event.ConfidenceBPS > 10_000 {
|
||||
return fmt.Errorf("event catalog: event %q has invalid impact metadata", event.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nthWeekday(year int, month time.Month, weekday time.Weekday, occurrence int, location *time.Location) time.Time {
|
||||
first := time.Date(year, month, 1, 0, 0, 0, 0, location)
|
||||
offset := (int(weekday) - int(first.Weekday()) + 7) % 7
|
||||
return first.AddDate(0, 0, offset+7*(occurrence-1))
|
||||
}
|
||||
|
||||
func validType(value EventType) bool {
|
||||
switch value {
|
||||
case TypePublicHoliday, TypeReligious, TypeNational, TypeCultural, TypeCommercial, TypeSeasonal, TypeEducation, TypeEconomic, TypeEmergency:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validTone(value Tone) bool {
|
||||
switch value {
|
||||
case ToneCelebratory, ToneJoyful, ToneNeutral, ToneSolemn, ToneMourning, ToneMemorial:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validPosture(value CommercialPosture) bool {
|
||||
return value == PosturePromote || value == PostureCaution || value == PostureNoPromotion
|
||||
}
|
||||
39
internal/eventcatalog/catalog_test.go
Normal file
39
internal/eventcatalog/catalog_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package eventcatalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResolveBlackFridayUsesRecurrenceRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := ResolveRecurrence(RecurrenceBlackFriday, 2026, time.UTC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "2026-11-27"; got.Format(time.DateOnly) != want {
|
||||
t.Fatalf("Black Friday = %s, want %s", got.Format(time.DateOnly), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventsDistinguishesCelebrationAndMourning(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
events := []Event{
|
||||
{ID: "nowruz-1405", TitleFA: "نوروز", StartDate: "2026-03-21", EndDate: "2026-03-24", Type: TypeCultural, Tone: ToneCelebratory, CommercialPosture: PosturePromote, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "1405", InclusionRationale: "تقویم رسمی", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000},
|
||||
{ID: "ashura-1405", TitleFA: "عاشورای حسینی", StartDate: "2026-06-25", EndDate: "2026-06-25", Type: TypeReligious, Tone: ToneMourning, CommercialPosture: PostureNoPromotion, SourceURI: "https://calendar.ut.ac.ir/", SourceVersion: "1405", InclusionRationale: "تقویم رسمی", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000},
|
||||
}
|
||||
if err := ValidateEvents(events); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEventsRejectsPromotionalMourningEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
event := Event{ID: "mourning", TitleFA: "یادبود", StartDate: "2026-01-01", EndDate: "2026-01-01", Type: TypeCultural, Tone: ToneMourning, CommercialPosture: PosturePromote, SourceURI: "https://example.test", SourceVersion: "v1", InclusionRationale: "reviewed", GeographicScope: "IR", RelevanceBPS: 10000, ConfidenceBPS: 10000}
|
||||
if err := ValidateEvents([]Event{event}); err == nil {
|
||||
t.Fatal("expected mourning promotion validation error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user