init push
This commit is contained in:
244
internal/shared/pkg/util/shift_clock.go
Normal file
244
internal/shared/pkg/util/shift_clock.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
basedomain "wucher/internal/domain/base"
|
||||
"wucher/internal/shared/pkg/suncalc"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrShiftScheduleMissing = errors.New("base shift schedule not found")
|
||||
ErrShiftTwilightMissing = errors.New("base twilight time not available")
|
||||
ErrShiftCoordinatesInvalid = errors.New("base coordinates invalid")
|
||||
)
|
||||
|
||||
func ResolveShiftWindow(b *basedomain.Base, date time.Time) (string, string, error) {
|
||||
if b == nil || date.IsZero() {
|
||||
return "", "", ErrShiftScheduleMissing
|
||||
}
|
||||
|
||||
shift := PickShiftForDate(b, date)
|
||||
loc := LocationFromUTCOffset(b.UTC)
|
||||
scheduleDay := calendarDate(date)
|
||||
scheduleDate := time.Date(scheduleDay.Year(), scheduleDay.Month(), scheduleDay.Day(), 12, 0, 0, 0, loc)
|
||||
|
||||
if shift == nil {
|
||||
if shiftWindowNeedsCoordinates(b.DefaultStartTimeType, b.DefaultEndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) {
|
||||
return "", "", ErrShiftCoordinatesInvalid
|
||||
}
|
||||
return resolveBaseDefaultShiftWindow(b, scheduleDate, loc)
|
||||
}
|
||||
if shiftWindowNeedsCoordinates(shift.StartTimeType, shift.EndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) {
|
||||
return "", "", ErrShiftCoordinatesInvalid
|
||||
}
|
||||
|
||||
start, err := resolveShiftClock(scheduleDate, loc, b, shift.StartTimeType, shift.ShiftStart, true)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
end, err := resolveShiftClock(scheduleDate, loc, b, shift.EndTimeType, shift.ShiftEnd, false)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func shiftWindowNeedsCoordinates(startTimeType, endTimeType string) bool {
|
||||
return NormalizeShiftTimeType(startTimeType) != basedomain.ShiftTimeTypeFixed ||
|
||||
NormalizeShiftTimeType(endTimeType) != basedomain.ShiftTimeTypeFixed
|
||||
}
|
||||
|
||||
func resolveBaseDefaultShiftWindow(b *basedomain.Base, scheduleDate time.Time, loc *time.Location) (string, string, error) {
|
||||
start, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultStartTimeType, b.DefaultShiftStart, true)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
end, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultEndTimeType, b.DefaultShiftEnd, false)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if start == "" || end == "" {
|
||||
return "", "", ErrShiftScheduleMissing
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func PickShiftForDate(b *basedomain.Base, date time.Time) *basedomain.BaseOperationalShiftTime {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
day := calendarDate(date)
|
||||
for i := range b.OperationalShiftTimes {
|
||||
row := &b.OperationalShiftTimes[i]
|
||||
if row.DateStart == nil || row.DateEnd == nil {
|
||||
continue
|
||||
}
|
||||
start := calendarDate(*row.DateStart)
|
||||
end := calendarDate(*row.DateEnd)
|
||||
if (day.Equal(start) || day.After(start)) && (day.Equal(end) || day.Before(end.Add(24*time.Hour))) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func calendarDate(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func DefaultShiftWindow(b *basedomain.Base) (string, string, error) {
|
||||
if b == nil {
|
||||
return "", "", ErrShiftScheduleMissing
|
||||
}
|
||||
start := NormalizeClockValue(b.DefaultShiftStart)
|
||||
end := NormalizeClockValue(b.DefaultShiftEnd)
|
||||
if start == "" || end == "" {
|
||||
return "", "", ErrShiftScheduleMissing
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func resolveShiftClock(date time.Time, loc *time.Location, b *basedomain.Base, timeType, fixedValue string, morning bool) (string, error) {
|
||||
switch NormalizeShiftTimeType(timeType) {
|
||||
case basedomain.ShiftTimeTypeFixed:
|
||||
clock := NormalizeClockValue(fixedValue)
|
||||
if clock == "" {
|
||||
return "", ErrShiftScheduleMissing
|
||||
}
|
||||
return clock, nil
|
||||
case basedomain.ShiftTimeTypeBMCT:
|
||||
return resolveSunCalcClock(date, loc, b, true)
|
||||
case basedomain.ShiftTimeTypeECET:
|
||||
return resolveSunCalcClock(date, loc, b, false)
|
||||
default:
|
||||
return "", fmt.Errorf("invalid shift time type %q", timeType)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveSunCalcClock(date time.Time, loc *time.Location, b *basedomain.Base, morning bool) (string, error) {
|
||||
twilight, err := suncalc.GetCivilTwilight(date, b.Latitude, b.Longitude)
|
||||
if err != nil {
|
||||
return "", ErrShiftTwilightMissing
|
||||
}
|
||||
var resolved time.Time
|
||||
if morning {
|
||||
resolved = twilight.Dawn
|
||||
} else {
|
||||
resolved = twilight.Dusk
|
||||
}
|
||||
if resolved.IsZero() {
|
||||
return "", ErrShiftTwilightMissing
|
||||
}
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
return resolved.In(loc).Format("15:04"), nil
|
||||
}
|
||||
|
||||
func NormalizeClockValue(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if len(trimmed) == 5 {
|
||||
trimmed += ":00"
|
||||
}
|
||||
layouts := []string{
|
||||
"15:04:05",
|
||||
"15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, trimmed)
|
||||
if err == nil {
|
||||
return parsed.UTC().Format("15:04")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func LocationFromUTCOffset(raw string) *time.Location {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || trimmed == "0" {
|
||||
return time.UTC
|
||||
}
|
||||
if loc, err := time.LoadLocation(trimmed); err == nil {
|
||||
return loc
|
||||
}
|
||||
if offset, ok := parseUTCOffset(trimmed); ok {
|
||||
return time.FixedZone("UTC", offset)
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
|
||||
func parseUTCOffset(raw string) (int, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
sign := 1
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "+"):
|
||||
trimmed = strings.TrimPrefix(trimmed, "+")
|
||||
case strings.HasPrefix(trimmed, "-"):
|
||||
sign = -1
|
||||
trimmed = strings.TrimPrefix(trimmed, "-")
|
||||
}
|
||||
trimmed = strings.TrimPrefix(strings.ToUpper(trimmed), "UTC")
|
||||
trimmed = strings.TrimSpace(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "+"):
|
||||
trimmed = strings.TrimPrefix(trimmed, "+")
|
||||
case strings.HasPrefix(trimmed, "-"):
|
||||
sign = -1
|
||||
trimmed = strings.TrimPrefix(trimmed, "-")
|
||||
}
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.Split(trimmed, ":")
|
||||
hours := 0
|
||||
minutes := 0
|
||||
switch len(parts) {
|
||||
case 1:
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
case 2:
|
||||
if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &minutes); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
total := ((hours * 3600) + (minutes * 60)) * sign
|
||||
return total, true
|
||||
}
|
||||
|
||||
func ValidCoordinates(lat, lon float64) bool {
|
||||
return !math.IsNaN(lat) && !math.IsNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
|
||||
}
|
||||
|
||||
func NormalizeShiftTimeType(raw string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "", basedomain.ShiftTimeTypeFixed:
|
||||
return basedomain.ShiftTimeTypeFixed
|
||||
case basedomain.ShiftTimeTypeBMCT:
|
||||
return basedomain.ShiftTimeTypeBMCT
|
||||
case basedomain.ShiftTimeTypeECET:
|
||||
return basedomain.ShiftTimeTypeECET
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(raw))
|
||||
}
|
||||
}
|
||||
70
internal/shared/pkg/util/shift_clock_test.go
Normal file
70
internal/shared/pkg/util/shift_clock_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
basedomain "wucher/internal/domain/base"
|
||||
)
|
||||
|
||||
func TestResolveShiftWindowUsesOperationalShiftWithinDateRange(t *testing.T) {
|
||||
base := &basedomain.Base{
|
||||
DefaultStartTimeType: basedomain.ShiftTimeTypeFixed,
|
||||
DefaultEndTimeType: basedomain.ShiftTimeTypeFixed,
|
||||
DefaultShiftStart: "09:00",
|
||||
DefaultShiftEnd: "21:00",
|
||||
OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{
|
||||
{
|
||||
DateStart: timePtr(time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC)),
|
||||
DateEnd: timePtr(time.Date(2026, time.June, 30, 0, 0, 0, 0, time.UTC)),
|
||||
StartTimeType: basedomain.ShiftTimeTypeFixed,
|
||||
EndTimeType: basedomain.ShiftTimeTypeFixed,
|
||||
ShiftStart: "10:00",
|
||||
ShiftEnd: "22:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
start, end, err := ResolveShiftWindow(base, time.Date(2026, time.June, 15, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveShiftWindow returned error: %v", err)
|
||||
}
|
||||
if start != "10:00" || end != "22:00" {
|
||||
t.Fatalf("expected operational shift 10:00-22:00, got %s-%s", start, end)
|
||||
}
|
||||
|
||||
start, end, err = ResolveShiftWindow(base, time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveShiftWindow returned error: %v", err)
|
||||
}
|
||||
if start != "09:00" || end != "21:00" {
|
||||
t.Fatalf("expected default shift 09:00-21:00, got %s-%s", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickShiftForDatePreservesCalendarDateFromLocalTimezone(t *testing.T) {
|
||||
jakarta := time.FixedZone("UTC+7", 7*60*60)
|
||||
shift := basedomain.BaseOperationalShiftTime{
|
||||
DateStart: timePtr(time.Date(2026, time.June, 1, 0, 0, 0, 0, jakarta)),
|
||||
DateEnd: timePtr(time.Date(2026, time.June, 30, 0, 0, 0, 0, jakarta)),
|
||||
StartTimeType: basedomain.ShiftTimeTypeFixed,
|
||||
EndTimeType: basedomain.ShiftTimeTypeFixed,
|
||||
ShiftStart: "10:00",
|
||||
ShiftEnd: "22:00",
|
||||
}
|
||||
base := &basedomain.Base{
|
||||
OperationalShiftTimes: []basedomain.BaseOperationalShiftTime{shift},
|
||||
}
|
||||
|
||||
picked := PickShiftForDate(base, time.Date(2026, time.June, 1, 0, 0, 0, 0, time.UTC))
|
||||
if picked == nil {
|
||||
t.Fatalf("expected operational shift to match calendar date from local timezone")
|
||||
}
|
||||
if picked.ShiftStart != "10:00" || picked.ShiftEnd != "22:00" {
|
||||
t.Fatalf("unexpected picked shift %s-%s", picked.ShiftStart, picked.ShiftEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func timePtr(v time.Time) *time.Time {
|
||||
return &v
|
||||
}
|
||||
Reference in New Issue
Block a user