package services import ( "fmt" "strings" "time" "github.com/jmoiron/sqlx" ) // Holiday represents a non-working day type Holiday struct { Date time.Time Name string IsVacation bool // Part of court vacation period IsClosure bool // Single-day closure (public holiday) } // HolidayService manages holiday data and non-working day checks type HolidayService struct { db *sqlx.DB // Cached holidays by year cache map[int][]Holiday } // NewHolidayService creates a holiday service func NewHolidayService(db *sqlx.DB) *HolidayService { return &HolidayService{ db: db, cache: make(map[int][]Holiday), } } // dbHoliday matches the holidays table schema type dbHoliday struct { ID int `db:"id"` Date time.Time `db:"date"` Name string `db:"name"` Country string `db:"country"` State *string `db:"state"` HolidayType string `db:"holiday_type"` } // LoadHolidaysForYear loads holidays from DB for a given year, merges with // German federal holidays, and caches the result. func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) { if cached, ok := s.cache[year]; ok { return cached, nil } holidays := make([]Holiday, 0, 30) // Load from DB if available if s.db != nil { var dbHolidays []dbHoliday err := s.db.Select(&dbHolidays, `SELECT id, date, name, country, state, holiday_type FROM holidays WHERE EXTRACT(YEAR FROM date) = $1 ORDER BY date`, year) if err == nil { for _, h := range dbHolidays { holidays = append(holidays, Holiday{ Date: h.Date, Name: h.Name, IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure", IsVacation: h.HolidayType == "vacation", }) } } // If DB query fails, fall through to hardcoded holidays } // Always add German federal holidays (if not already present from DB) federal := germanFederalHolidays(year) existing := make(map[string]bool, len(holidays)) for _, h := range holidays { existing[h.Date.Format("2006-01-02")] = true } for _, h := range federal { key := h.Date.Format("2006-01-02") if !existing[key] { holidays = append(holidays, h) } } s.cache[year] = holidays return holidays, nil } // IsHoliday checks if a date is a holiday func (s *HolidayService) IsHoliday(date time.Time) *Holiday { year := date.Year() holidays, err := s.LoadHolidaysForYear(year) if err != nil { return nil } dateStr := date.Format("2006-01-02") for i := range holidays { if holidays[i].Date.Format("2006-01-02") == dateStr { return &holidays[i] } } return nil } // IsNonWorkingDay returns true if the date is a weekend or holiday func (s *HolidayService) IsNonWorkingDay(date time.Time) bool { wd := date.Weekday() if wd == time.Saturday || wd == time.Sunday { return true } return s.IsHoliday(date) != nil } // AdjustForNonWorkingDays moves the date to the next working day // if it falls on a weekend or holiday. // Returns adjusted date, original date, and whether adjustment was made. func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) { original = date adjusted = date // Safety limit: max 30 days forward for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ { adjusted = adjusted.AddDate(0, 0, 1) wasAdjusted = true } return adjusted, original, wasAdjusted } // ClearCache clears the holiday cache (useful after DB updates) func (s *HolidayService) ClearCache() { s.cache = make(map[int][]Holiday) } // germanFederalHolidays returns all German federal public holidays for a year. // These are holidays observed in all 16 German states. func germanFederalHolidays(year int) []Holiday { easterMonth, easterDay := CalculateEasterSunday(year) easter := time.Date(year, time.Month(easterMonth), easterDay, 0, 0, 0, 0, time.UTC) holidays := []Holiday{ {Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", IsClosure: true}, {Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", IsClosure: true}, {Date: easter, Name: "Ostersonntag", IsClosure: true}, {Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", IsClosure: true}, {Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", IsClosure: true}, {Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", IsClosure: true}, {Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", IsClosure: true}, {Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", IsClosure: true}, {Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", IsClosure: true}, {Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", IsClosure: true}, {Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", IsClosure: true}, } return holidays } // CalculateEasterSunday computes Easter Sunday using the Anonymous Gregorian algorithm. // Returns month (1-12) and day. func CalculateEasterSunday(year int) (int, int) { a := year % 19 b := year / 100 c := year % 100 d := b / 4 e := b % 4 f := (b + 8) / 25 g := (b - f + 1) / 3 h := (19*a + b - d - g + 15) % 30 i := c / 4 k := c % 4 l := (32 + 2*e + 2*i - h - k) % 7 m := (a + 11*h + 22*l) / 451 month := (h + l - 7*m + 114) / 31 day := ((h + l - 7*m + 114) % 31) + 1 return month, day } // GetHolidaysForYear returns all holidays for a year (for API exposure) func (s *HolidayService) GetHolidaysForYear(year int) ([]Holiday, error) { return s.LoadHolidaysForYear(year) } // FormatHolidayList returns a simple string representation of holidays for debugging func FormatHolidayList(holidays []Holiday) string { var b strings.Builder for _, h := range holidays { fmt.Fprintf(&b, "%s: %s\n", h.Date.Format("2006-01-02"), h.Name) } return b.String() }