diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4857b16 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +frontend/node_modules +frontend/dist +backend/tmp +backend/bin +backend/ui/dist +.git +.claude +*.local +.env +.env.* diff --git a/.gitignore b/.gitignore index 51d61fb..bde5951 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,6 @@ yarn-error.log* # Docker *.pid -.dockerignore # OS .DS_Store diff --git a/backend/internal/api/handlers/dto/schedule_request.go b/backend/internal/api/handlers/dto/schedule_request.go index 9ccda51..221fa8d 100644 --- a/backend/internal/api/handlers/dto/schedule_request.go +++ b/backend/internal/api/handlers/dto/schedule_request.go @@ -173,8 +173,8 @@ func (r *CreateUnavailabilityRequest) ToModel(scheduleID uuid.UUID) *models.Sche return &models.ScheduleUnavailability{ ScheduleID: scheduleID, UserName: r.UserName, - StartDate: r.StartDate.UTC().Truncate(24 * time.Hour), - EndDate: r.EndDate.UTC().Truncate(24 * time.Hour), + StartDate: models.DateOnly(r.StartDate.UTC().Format("2006-01-02")), + EndDate: models.DateOnly(r.EndDate.UTC().Format("2006-01-02")), Reason: r.Reason, CreatedBy: createdBy, } diff --git a/backend/internal/api/handlers/dto/schedule_response.go b/backend/internal/api/handlers/dto/schedule_response.go index 7bf792b..8a5ca28 100644 --- a/backend/internal/api/handlers/dto/schedule_response.go +++ b/backend/internal/api/handlers/dto/schedule_response.go @@ -82,8 +82,8 @@ func ToUnavailabilityResponse(u *models.ScheduleUnavailability) UnavailabilityRe ID: u.ID, ScheduleID: u.ScheduleID, UserName: u.UserName, - StartDate: u.StartDate.UTC().Format("2006-01-02"), - EndDate: u.EndDate.UTC().Format("2006-01-02"), + StartDate: u.StartDate.String(), + EndDate: u.EndDate.String(), Reason: u.Reason, CreatedBy: u.CreatedBy, CreatedAt: u.CreatedAt, @@ -112,7 +112,7 @@ func ToHolidayResponse(h *models.ScheduleHoliday) HolidayResponse { ID: h.ID, ScheduleID: h.ScheduleID, CountryCode: h.CountryCode, - Date: h.Date.Format("2006-01-02"), + Date: h.Date.String(), Name: h.Name, } } diff --git a/backend/internal/models/date_only.go b/backend/internal/models/date_only.go new file mode 100644 index 0000000..8597aa8 --- /dev/null +++ b/backend/internal/models/date_only.go @@ -0,0 +1,62 @@ +package models + +import ( + "database/sql/driver" + "fmt" + "time" +) + +// DateOnly is a date-only value stored and transmitted as "YYYY-MM-DD". +// Using this instead of time.Time prevents accidental timezone shifts when a +// time.Time (midnight UTC) is converted to local time in the browser or on a +// server not running UTC. +type DateOnly string + +// Scan implements sql.Scanner so GORM can populate DateOnly from a DATE column. +// pgx delivers DATE values as time.Time (midnight UTC); we extract the UTC date string. +func (d *DateOnly) Scan(value interface{}) error { + switch v := value.(type) { + case time.Time: + *d = DateOnly(v.UTC().Format("2006-01-02")) + case string: + if _, err := time.Parse("2006-01-02", v); err != nil { + return fmt.Errorf("DateOnly: invalid date string %q", v) + } + *d = DateOnly(v) + case []byte: + s := string(v) + if _, err := time.Parse("2006-01-02", s); err != nil { + return fmt.Errorf("DateOnly: invalid date bytes %q", s) + } + *d = DateOnly(s) + case nil: + *d = "" + default: + return fmt.Errorf("DateOnly: cannot scan %T", value) + } + return nil +} + +// Value implements driver.Valuer so GORM can write DateOnly back to a DATE column. +func (d DateOnly) Value() (driver.Value, error) { + if d == "" { + return nil, nil + } + return string(d), nil +} + +// String returns the date as "YYYY-MM-DD". +func (d DateOnly) String() string { return string(d) } + +// ParseDateOnly parses a "YYYY-MM-DD" string into a DateOnly. +func ParseDateOnly(s string) (DateOnly, error) { + if _, err := time.Parse("2006-01-02", s); err != nil { + return "", fmt.Errorf("DateOnly: %w", err) + } + return DateOnly(s), nil +} + +// DateOnlyFromTime converts a time.Time to DateOnly using its UTC date. +func DateOnlyFromTime(t time.Time) DateOnly { + return DateOnly(t.UTC().Format("2006-01-02")) +} diff --git a/backend/internal/models/date_only_test.go b/backend/internal/models/date_only_test.go new file mode 100644 index 0000000..7f514e3 --- /dev/null +++ b/backend/internal/models/date_only_test.go @@ -0,0 +1,110 @@ +package models + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDateOnly_ScanTimeTime(t *testing.T) { + // Midnight UTC — the format pgx delivers DATE columns + ts := time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC) + var d DateOnly + require.NoError(t, d.Scan(ts)) + assert.Equal(t, DateOnly("2026-05-24"), d) +} + +func TestDateOnly_ScanTimeTime_NonMidnight(t *testing.T) { + // Any non-zero time on the same UTC day must still give the UTC date + ts := time.Date(2026, 5, 24, 18, 30, 0, 0, time.UTC) + var d DateOnly + require.NoError(t, d.Scan(ts)) + assert.Equal(t, DateOnly("2026-05-24"), d) +} + +func TestDateOnly_ScanTimeTime_UTC_Plus(t *testing.T) { + // time.Time in UTC+5:30 at local midnight: 2026-05-24T00:00:00+05:30 + // UTC equivalent: 2026-05-23T18:30:00Z — UTC date is May 23 + // Scan must use UTC to yield "2026-05-23", not "2026-05-24" + ist := time.FixedZone("IST", 5*3600+30*60) + ts := time.Date(2026, 5, 24, 0, 0, 0, 0, ist) // local midnight IST + var d DateOnly + require.NoError(t, d.Scan(ts)) + // UTC date for this instant is 2026-05-23 + assert.Equal(t, DateOnly("2026-05-23"), d) +} + +func TestDateOnly_ScanString(t *testing.T) { + var d DateOnly + require.NoError(t, d.Scan("2026-05-24")) + assert.Equal(t, DateOnly("2026-05-24"), d) +} + +func TestDateOnly_ScanString_Invalid(t *testing.T) { + var d DateOnly + assert.Error(t, d.Scan("24-05-2026")) +} + +func TestDateOnly_ScanNil(t *testing.T) { + var d DateOnly + require.NoError(t, d.Scan(nil)) + assert.Equal(t, DateOnly(""), d) +} + +func TestDateOnly_Value(t *testing.T) { + d := DateOnly("2026-05-24") + v, err := d.Value() + require.NoError(t, err) + assert.Equal(t, "2026-05-24", v) +} + +func TestDateOnly_Value_Empty(t *testing.T) { + d := DateOnly("") + v, err := d.Value() + require.NoError(t, err) + assert.Nil(t, v) +} + +func TestDateOnly_JSONMarshal(t *testing.T) { + d := DateOnly("2026-05-24") + b, err := json.Marshal(d) + require.NoError(t, err) + assert.Equal(t, `"2026-05-24"`, string(b)) +} + +func TestDateOnly_JSONUnmarshal(t *testing.T) { + var d DateOnly + require.NoError(t, json.Unmarshal([]byte(`"2026-05-24"`), &d)) + assert.Equal(t, DateOnly("2026-05-24"), d) +} + +func TestDateOnly_JSONRoundTrip_InStruct(t *testing.T) { + type payload struct { + Date DateOnly `json:"date"` + } + in := payload{Date: "2026-05-24"} + b, err := json.Marshal(in) + require.NoError(t, err) + assert.Contains(t, string(b), `"date":"2026-05-24"`) + + var out payload + require.NoError(t, json.Unmarshal(b, &out)) + assert.Equal(t, in, out) +} + +func TestDateOnlyFromTime(t *testing.T) { + ts := time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC) + assert.Equal(t, DateOnly("2026-05-24"), DateOnlyFromTime(ts)) +} + +func TestParseeDateOnly(t *testing.T) { + d, err := ParseDateOnly("2026-05-24") + require.NoError(t, err) + assert.Equal(t, DateOnly("2026-05-24"), d) + + _, err = ParseDateOnly("not-a-date") + assert.Error(t, err) +} diff --git a/backend/internal/models/schedule.go b/backend/internal/models/schedule.go index 059e88f..340044c 100644 --- a/backend/internal/models/schedule.go +++ b/backend/internal/models/schedule.go @@ -216,10 +216,10 @@ type ScheduleUnavailability struct { UserName string `gorm:"type:varchar(255);not null" json:"user_name"` // StartDate is the first day of unavailability (inclusive, stored as DATE). - StartDate time.Time `gorm:"type:date;not null" json:"start_date"` + StartDate DateOnly `gorm:"type:date;not null" json:"start_date"` // EndDate is the last day of unavailability (inclusive, stored as DATE). - EndDate time.Time `gorm:"type:date;not null" json:"end_date"` + EndDate DateOnly `gorm:"type:date;not null" json:"end_date"` // Reason is an optional human-readable explanation (e.g., "PTO", "sick leave"). Reason string `gorm:"type:varchar(500)" json:"reason,omitempty"` @@ -242,7 +242,7 @@ type ScheduleHoliday struct { ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` ScheduleID uuid.UUID `gorm:"type:uuid;not null;index" json:"schedule_id"` CountryCode string `gorm:"type:varchar(10);not null" json:"country_code"` - Date time.Time `gorm:"type:date;not null" json:"date"` + Date DateOnly `gorm:"type:date;not null" json:"date"` Name string `gorm:"type:varchar(255);not null" json:"name"` CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` } diff --git a/backend/internal/repository/schedule_repository.go b/backend/internal/repository/schedule_repository.go index 1d2d3b0..9847149 100644 --- a/backend/internal/repository/schedule_repository.go +++ b/backend/internal/repository/schedule_repository.go @@ -430,7 +430,7 @@ func (r *scheduleRepository) UpsertHolidays(holidays []models.ScheduleHoliday) e seen := make(map[string]struct{}, len(holidays)) deduped := make([]models.ScheduleHoliday, 0, len(holidays)) for _, h := range holidays { - key := h.ScheduleID.String() + "|" + h.CountryCode + "|" + h.Date.Format("2006-01-02") + key := h.ScheduleID.String() + "|" + h.CountryCode + "|" + h.Date.String() if _, exists := seen[key]; exists { continue } @@ -587,9 +587,7 @@ func validateUnavailability(u *models.ScheduleUnavailability) error { if u.UserName == "" { return fmt.Errorf("user_name cannot be empty") } - startDate := u.StartDate.UTC().Truncate(24 * time.Hour) - endDate := u.EndDate.UTC().Truncate(24 * time.Hour) - if endDate.Before(startDate) { + if u.EndDate < u.StartDate { return fmt.Errorf("end_date must be on or after start_date") } return nil diff --git a/backend/internal/repository/unavailability_validation_test.go b/backend/internal/repository/unavailability_validation_test.go index 36a922b..5b81bc5 100644 --- a/backend/internal/repository/unavailability_validation_test.go +++ b/backend/internal/repository/unavailability_validation_test.go @@ -5,22 +5,17 @@ package repository import ( "testing" - "time" "github.com/FluidifyAI/Regen/backend/internal/models" "github.com/google/uuid" ) -func midnightUTCForTest(year, month, day int) time.Time { - return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) -} - func TestValidateUnavailability_Valid(t *testing.T) { u := &models.ScheduleUnavailability{ ScheduleID: uuid.New(), UserName: "alice", - StartDate: midnightUTCForTest(2026, 5, 10), - EndDate: midnightUTCForTest(2026, 5, 14), + StartDate: "2026-05-10", + EndDate: "2026-05-14", } if err := validateUnavailability(u); err != nil { t.Errorf("expected no error for valid unavailability, got: %v", err) @@ -31,8 +26,8 @@ func TestValidateUnavailability_SingleDay(t *testing.T) { u := &models.ScheduleUnavailability{ ScheduleID: uuid.New(), UserName: "bob", - StartDate: midnightUTCForTest(2026, 5, 10), - EndDate: midnightUTCForTest(2026, 5, 10), // same day — valid + StartDate: "2026-05-10", + EndDate: "2026-05-10", } if err := validateUnavailability(u); err != nil { t.Errorf("start == end (single day) should be valid, got: %v", err) @@ -43,8 +38,8 @@ func TestValidateUnavailability_MissingScheduleID(t *testing.T) { u := &models.ScheduleUnavailability{ ScheduleID: uuid.Nil, UserName: "alice", - StartDate: midnightUTCForTest(2026, 5, 10), - EndDate: midnightUTCForTest(2026, 5, 14), + StartDate: "2026-05-10", + EndDate: "2026-05-14", } if err := validateUnavailability(u); err == nil { t.Error("expected error for nil ScheduleID, got nil") @@ -55,8 +50,8 @@ func TestValidateUnavailability_EmptyUserName(t *testing.T) { u := &models.ScheduleUnavailability{ ScheduleID: uuid.New(), UserName: "", - StartDate: midnightUTCForTest(2026, 5, 10), - EndDate: midnightUTCForTest(2026, 5, 14), + StartDate: "2026-05-10", + EndDate: "2026-05-14", } if err := validateUnavailability(u); err == nil { t.Error("expected error for empty user_name, got nil") @@ -67,8 +62,8 @@ func TestValidateUnavailability_EndBeforeStart(t *testing.T) { u := &models.ScheduleUnavailability{ ScheduleID: uuid.New(), UserName: "alice", - StartDate: midnightUTCForTest(2026, 5, 14), - EndDate: midnightUTCForTest(2026, 5, 10), // before start + StartDate: "2026-05-14", + EndDate: "2026-05-10", } if err := validateUnavailability(u); err == nil { t.Error("expected error when end_date is before start_date, got nil") @@ -79,8 +74,8 @@ func TestValidateUnavailability_EndBeforeStart_ByOneDay(t *testing.T) { u := &models.ScheduleUnavailability{ ScheduleID: uuid.New(), UserName: "alice", - StartDate: midnightUTCForTest(2026, 5, 11), - EndDate: midnightUTCForTest(2026, 5, 10), // one day before start + StartDate: "2026-05-11", + EndDate: "2026-05-10", } if err := validateUnavailability(u); err == nil { t.Error("expected error when end_date is one day before start_date, got nil") diff --git a/backend/internal/services/holiday_service.go b/backend/internal/services/holiday_service.go index eed890d..4bb96b3 100644 --- a/backend/internal/services/holiday_service.go +++ b/backend/internal/services/holiday_service.go @@ -120,7 +120,7 @@ func (s *HolidayService) fetchHolidays(scheduleID uuid.UUID, code, url string) ( // holidayEntry is a parsed (date, name) pair from an ICS feed. type holidayEntry struct { - date time.Time + date models.DateOnly name string } @@ -142,7 +142,7 @@ func parseICS(r io.Reader) ([]holidayEntry, error) { inEvent = true cur = holidayEntry{} case line == "END:VEVENT": - if inEvent && !cur.date.IsZero() && cur.name != "" { + if inEvent && cur.date != "" && cur.name != "" { entries = append(entries, cur) } inEvent = false @@ -151,7 +151,7 @@ func parseICS(r io.Reader) ([]holidayEntry, error) { if idx := strings.LastIndex(line, ":"); idx >= 0 { val := strings.TrimSpace(line[idx+1:]) if t, err := time.Parse("20060102", val); err == nil { - cur.date = t.UTC() + cur.date = models.DateOnlyFromTime(t) } } case inEvent && strings.HasPrefix(line, "SUMMARY:"): diff --git a/backend/internal/services/holiday_service_test.go b/backend/internal/services/holiday_service_test.go index 26f2b1d..076afdf 100644 --- a/backend/internal/services/holiday_service_test.go +++ b/backend/internal/services/holiday_service_test.go @@ -39,9 +39,7 @@ func TestParseICS_BasicEvent(t *testing.T) { require.NoError(t, err) require.Len(t, entries, 1) assert.Equal(t, "New Year's Day", entries[0].name) - assert.Equal(t, 2026, entries[0].date.Year()) - assert.Equal(t, 1, int(entries[0].date.Month())) - assert.Equal(t, 1, entries[0].date.Day()) + assert.Equal(t, "2026-01-01", entries[0].date.String()) } func TestParseICS_DtStartNoValueType(t *testing.T) { @@ -51,8 +49,7 @@ func TestParseICS_DtStartNoValueType(t *testing.T) { require.NoError(t, err) require.Len(t, entries, 1) assert.Equal(t, "Independence Day", entries[0].name) - assert.Equal(t, 8, int(entries[0].date.Month())) - assert.Equal(t, 15, entries[0].date.Day()) + assert.Equal(t, "2026-08-15", entries[0].date.String()) } func TestParseICS_MultipleEvents(t *testing.T) { diff --git a/backend/internal/services/schedule_evaluator.go b/backend/internal/services/schedule_evaluator.go index 44743c3..08a5f48 100644 --- a/backend/internal/services/schedule_evaluator.go +++ b/backend/internal/services/schedule_evaluator.go @@ -74,7 +74,8 @@ func (e *scheduleEvaluator) WhoIsOnCall(scheduleID uuid.UUID, at time.Time) (str return "", fmt.Errorf("failed to load unavailabilities: %w", err) } - unavailableAt := buildUnavailableSet(unavailabilities, at) + loc := scheduleLocation(schedule.Timezone) + unavailableAt := buildUnavailableSet(unavailabilities, at, loc) return computeOnCallFromLayersSkipping(schedule.Layers, at, unavailableAt), nil } @@ -229,7 +230,8 @@ func buildTimeline( unavailabilities []models.ScheduleUnavailability, from, to time.Time, ) []TimelineSegment { - boundaries := collectBoundaries(schedule.Layers, overrides, unavailabilities, from, to) + loc := scheduleLocation(schedule.Timezone) + boundaries := collectBoundaries(schedule.Layers, overrides, unavailabilities, from, to, loc) var segments []TimelineSegment for i := 0; i < len(boundaries)-1; i++ { @@ -237,7 +239,7 @@ func buildTimeline( end := boundaries[i+1] mid := start.Add(end.Sub(start) / 2) - isOverride, user := resolveAtTime(schedule.Layers, overrides, unavailabilities, mid) + isOverride, user := resolveAtTime(schedule.Layers, overrides, unavailabilities, mid, loc) if user == "" { user = "(nobody)" } @@ -266,6 +268,7 @@ func collectBoundaries( overrides []models.ScheduleOverride, unavailabilities []models.ScheduleUnavailability, from, to time.Time, + loc *time.Location, ) []time.Time { seen := map[time.Time]struct{}{ from: {}, @@ -307,11 +310,11 @@ func collectBoundaries( } } - // Unavailability day boundaries: add midnight UTC at start_date and the - // midnight after end_date (the day when availability resumes). + // Unavailability day boundaries: midnight in the schedule's timezone at start_date, + // and midnight of the day after end_date (when availability resumes). for _, u := range unavailabilities { - startMidnight := u.StartDate.UTC().Truncate(24 * time.Hour) - resumeMidnight := u.EndDate.UTC().Truncate(24*time.Hour).Add(24 * time.Hour) + startMidnight := dateMidnightInLoc(string(u.StartDate), loc) + resumeMidnight := dateMidnightInLoc(string(u.EndDate), loc).Add(24 * time.Hour) if startMidnight.After(from) && startMidnight.Before(to) { seen[startMidnight] = struct{}{} } @@ -337,30 +340,52 @@ func resolveAtTime( overrides []models.ScheduleOverride, unavailabilities []models.ScheduleUnavailability, at time.Time, + loc *time.Location, ) (bool, string) { for _, ov := range overrides { if !at.Before(ov.StartTime) && at.Before(ov.EndTime) { return true, ov.OverrideUser } } - unavailable := buildUnavailableSet(unavailabilities, at) + unavailable := buildUnavailableSet(unavailabilities, at, loc) return false, computeOnCallFromLayersSkipping(layers, at, unavailable) } // buildUnavailableSet returns a set of usernames who are unavailable at the given time. -// Unavailability is date-granular: a user is unavailable for the full UTC day. -func buildUnavailableSet(unavailabilities []models.ScheduleUnavailability, at time.Time) map[string]struct{} { +// Unavailability is date-granular: a user is unavailable for the entire calendar day +// as defined in the schedule's timezone (loc). +func buildUnavailableSet(unavailabilities []models.ScheduleUnavailability, at time.Time, loc *time.Location) map[string]struct{} { if len(unavailabilities) == 0 { return nil } - atDate := at.UTC().Truncate(24 * time.Hour) + atDate := at.In(loc).Format("2006-01-02") unavailable := make(map[string]struct{}) for _, u := range unavailabilities { - startDate := u.StartDate.UTC().Truncate(24 * time.Hour) - endDate := u.EndDate.UTC().Truncate(24 * time.Hour) - if !atDate.Before(startDate) && !atDate.After(endDate) { + if atDate >= string(u.StartDate) && atDate <= string(u.EndDate) { unavailable[strings.ToLower(u.UserName)] = struct{}{} } } return unavailable } + +// scheduleLocation parses an IANA timezone name, falling back to UTC on error. +func scheduleLocation(tz string) *time.Location { + if tz == "" { + return time.UTC + } + loc, err := time.LoadLocation(tz) + if err != nil { + return time.UTC + } + return loc +} + +// dateMidnightInLoc returns midnight of the given "YYYY-MM-DD" date in loc, normalized +// to UTC so it can be used safely as a map key or compared with other UTC times. +func dateMidnightInLoc(date string, loc *time.Location) time.Time { + t, err := time.ParseInLocation("2006-01-02", date, loc) + if err != nil { + t, _ = time.Parse("2006-01-02", date) + } + return t.UTC() +} diff --git a/backend/internal/services/schedule_evaluator_test.go b/backend/internal/services/schedule_evaluator_test.go index 5781998..d4ac1ea 100644 --- a/backend/internal/services/schedule_evaluator_test.go +++ b/backend/internal/services/schedule_evaluator_test.go @@ -31,13 +31,14 @@ func makeLayer(rotationStart time.Time, shiftDuration time.Duration, participant } // makeUnavailability builds a ScheduleUnavailability for the given user over [start, end] (inclusive dates). +// start/end are UTC midnight values produced by day(), so DateOnlyFromTime gives the correct YYYY-MM-DD. func makeUnavailability(scheduleID uuid.UUID, user string, start, end time.Time) models.ScheduleUnavailability { return models.ScheduleUnavailability{ ID: uuid.New(), ScheduleID: scheduleID, UserName: user, - StartDate: start, - EndDate: end, + StartDate: models.DateOnlyFromTime(start), + EndDate: models.DateOnlyFromTime(end), } } @@ -45,7 +46,7 @@ func makeUnavailability(scheduleID uuid.UUID, user string, start, end time.Time) func TestBuildUnavailableSet_NoUnavailabilities(t *testing.T) { at := day(2026, 5, 10) - result := buildUnavailableSet(nil, at) + result := buildUnavailableSet(nil, at, time.UTC) if len(result) != 0 { t.Errorf("expected empty set, got %v", result) } @@ -55,7 +56,7 @@ func TestBuildUnavailableSet_ActiveOnExactDay(t *testing.T) { schedID := uuid.New() at := day(2026, 5, 10) u := makeUnavailability(schedID, "alice", day(2026, 5, 10), day(2026, 5, 10)) - result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at, time.UTC) if _, ok := result["alice"]; !ok { t.Error("alice should be unavailable on her start=end date") } @@ -65,7 +66,7 @@ func TestBuildUnavailableSet_ActiveMidRange(t *testing.T) { schedID := uuid.New() at := day(2026, 5, 12) u := makeUnavailability(schedID, "bob", day(2026, 5, 10), day(2026, 5, 14)) - result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at, time.UTC) if _, ok := result["bob"]; !ok { t.Error("bob should be unavailable in the middle of his range") } @@ -75,7 +76,7 @@ func TestBuildUnavailableSet_ExpiredYesterday(t *testing.T) { schedID := uuid.New() at := day(2026, 5, 10) u := makeUnavailability(schedID, "carol", day(2026, 5, 8), day(2026, 5, 9)) - result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at, time.UTC) if _, ok := result["carol"]; ok { t.Error("carol's unavailability ended yesterday; she should be available today") } @@ -85,7 +86,7 @@ func TestBuildUnavailableSet_StartsTimorrow(t *testing.T) { schedID := uuid.New() at := day(2026, 5, 10) u := makeUnavailability(schedID, "dave", day(2026, 5, 11), day(2026, 5, 15)) - result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at, time.UTC) if _, ok := result["dave"]; ok { t.Error("dave's unavailability starts tomorrow; he should be available today") } @@ -99,7 +100,7 @@ func TestBuildUnavailableSet_MultipleUsers(t *testing.T) { makeUnavailability(schedID, "bob", day(2026, 5, 10), day(2026, 5, 10)), makeUnavailability(schedID, "carol", day(2026, 5, 11), day(2026, 5, 13)), // tomorrow } - result := buildUnavailableSet(unavails, at) + result := buildUnavailableSet(unavails, at, time.UTC) if _, ok := result["alice"]; !ok { t.Error("alice should be unavailable") } @@ -116,7 +117,7 @@ func TestBuildUnavailableSet_MidnightBoundary_StartOfDay(t *testing.T) { // Check: 00:00:01 UTC on start day → still in range at := day(2026, 5, 10).Add(time.Second) u := makeUnavailability(schedID, "alice", day(2026, 5, 10), day(2026, 5, 10)) - result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at, time.UTC) if _, ok := result["alice"]; !ok { t.Error("alice should be unavailable at 00:00:01 on her start date") } @@ -127,7 +128,7 @@ func TestBuildUnavailableSet_MidnightBoundary_LastSecondOfEndDay(t *testing.T) { // Check: 23:59:59 UTC on end day → still in range at := day(2026, 5, 10).Add(24*time.Hour - time.Second) u := makeUnavailability(schedID, "alice", day(2026, 5, 10), day(2026, 5, 10)) - result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at, time.UTC) if _, ok := result["alice"]; !ok { t.Error("alice should be unavailable at 23:59:59 on her end date") } @@ -244,7 +245,7 @@ func TestBuildUnavailableSet_CaseInsensitive_StoresLowercaseKey(t *testing.T) { at := day(2026, 5, 10) // UserName stored with capital — should appear as lowercase key in the returned set. u := makeUnavailability(schedID, "Alice", day(2026, 5, 10), day(2026, 5, 10)) - result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at, time.UTC) if _, ok := result["alice"]; !ok { t.Error("expected lowercase key 'alice' in unavailable set for input UserName 'Alice'") } @@ -253,6 +254,68 @@ func TestBuildUnavailableSet_CaseInsensitive_StoresLowercaseKey(t *testing.T) { } } +func TestBuildUnavailableSet_UsesScheduleTimezone_IST(t *testing.T) { + // User marks DATE 2026-05-28 as unavailable. Schedule is Asia/Kolkata (UTC+5:30). + // Correct behaviour: the leave covers the full IST calendar day May 28, + // i.e. from 2026-05-27T18:30Z to 2026-05-28T18:30Z. + ist, err := time.LoadLocation("Asia/Kolkata") + if err != nil { + t.Skip("Asia/Kolkata timezone not available") + } + + schedID := uuid.New() + u := makeUnavailability(schedID, "alice", day(2026, 5, 28), day(2026, 5, 28)) + + // 00:01 IST on May 28 = 2026-05-27T18:31Z → alice's leave day in IST → UNAVAILABLE + at0001May28IST := time.Date(2026, 5, 28, 0, 1, 0, 0, ist) + result := buildUnavailableSet([]models.ScheduleUnavailability{u}, at0001May28IST, ist) + if _, ok := result["alice"]; !ok { + t.Error("alice should be unavailable at 00:01 IST on her leave date May 28") + } + + // 00:01 IST on May 29 = 2026-05-28T18:31Z → next day in IST → AVAILABLE + at0001May29IST := time.Date(2026, 5, 29, 0, 1, 0, 0, ist) + result = buildUnavailableSet([]models.ScheduleUnavailability{u}, at0001May29IST, ist) + if _, ok := result["alice"]; ok { + t.Error("alice should NOT be unavailable at 00:01 IST on May 29 (leave was only for May 28)") + } +} + +func TestCollectBoundaries_UsesScheduleTimezone_IST(t *testing.T) { + // Schedule timezone Asia/Kolkata (UTC+5:30). + // Unavailability for DATE 2026-05-28. + // Expected boundaries: + // startMidnight = 2026-05-28T00:00 IST = 2026-05-27T18:30Z + // resumeMidnight = 2026-05-29T00:00 IST = 2026-05-28T18:30Z + ist, err := time.LoadLocation("Asia/Kolkata") + if err != nil { + t.Skip("Asia/Kolkata timezone not available") + } + + schedID := uuid.New() + epoch := day(2026, 5, 1) + layer := makeLayer(epoch, 7*24*time.Hour, "alice", "bob") + from := time.Date(2026, 5, 27, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 5, 30, 0, 0, 0, 0, time.UTC) + + unavail := makeUnavailability(schedID, "alice", day(2026, 5, 28), day(2026, 5, 28)) + bounds := collectBoundaries([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{unavail}, from, to, ist) + + boundSet := make(map[time.Time]struct{}, len(bounds)) + for _, b := range bounds { + boundSet[b] = struct{}{} + } + + wantStart := time.Date(2026, 5, 27, 18, 30, 0, 0, time.UTC) // midnight IST May 28 + wantResume := time.Date(2026, 5, 28, 18, 30, 0, 0, time.UTC) // midnight IST May 29 + if _, ok := boundSet[wantStart]; !ok { + t.Errorf("expected IST-midnight start boundary at %v", wantStart) + } + if _, ok := boundSet[wantResume]; !ok { + t.Errorf("expected IST-midnight resume boundary at %v", wantResume) + } +} + // ── computeOnCallFromLayersSkipping ────────────────────────────────────────── func TestComputeOnCallFromLayersSkipping_FallsThrough_WhenLayerFullyUnavailable(t *testing.T) { @@ -307,7 +370,7 @@ func TestCollectBoundaries_IncludesUnavailabilityDayBoundaries(t *testing.T) { to := day(2026, 5, 15) unavail := makeUnavailability(schedID, "alice", day(2026, 5, 8), day(2026, 5, 10)) - bounds := collectBoundaries([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{unavail}, from, to) + bounds := collectBoundaries([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{unavail}, from, to, time.UTC) boundSet := make(map[time.Time]struct{}, len(bounds)) for _, b := range bounds { @@ -330,8 +393,8 @@ func TestCollectBoundaries_NoUnavailabilities_BoundariesUnchanged(t *testing.T) from := day(2026, 5, 1) to := day(2026, 5, 15) - withNone := collectBoundaries([]models.ScheduleLayer{layer}, nil, nil, from, to) - withEmpty := collectBoundaries([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{}, from, to) + withNone := collectBoundaries([]models.ScheduleLayer{layer}, nil, nil, from, to, time.UTC) + withEmpty := collectBoundaries([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{}, from, to, time.UTC) if len(withNone) != len(withEmpty) { t.Errorf("nil and empty unavailabilities should produce same boundary count: %d vs %d", len(withNone), len(withEmpty)) @@ -347,7 +410,7 @@ func TestCollectBoundaries_UnavailabilityOutsideWindow_NotAdded(t *testing.T) { // Unavailability entirely after the window unavail := makeUnavailability(schedID, "alice", day(2026, 5, 20), day(2026, 5, 25)) - bounds := collectBoundaries([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{unavail}, from, to) + bounds := collectBoundaries([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{unavail}, from, to, time.UTC) for _, b := range bounds { if b.Equal(day(2026, 5, 20)) || b.Equal(day(2026, 5, 26)) { @@ -482,7 +545,7 @@ func TestResolveAtTime_NoOverrides_NoUnavailabilities(t *testing.T) { epoch := day(2026, 1, 1) layer := makeLayer(epoch, 24*time.Hour, "alice", "bob") - isOverride, user := resolveAtTime([]models.ScheduleLayer{layer}, nil, nil, epoch) + isOverride, user := resolveAtTime([]models.ScheduleLayer{layer}, nil, nil, epoch, time.UTC) if isOverride { t.Error("should not be override") } @@ -497,7 +560,7 @@ func TestResolveAtTime_UnavailableUser_AdvancesRotation(t *testing.T) { layer := makeLayer(epoch, 24*time.Hour, "alice", "bob") unavail := makeUnavailability(schedID, "alice", day(2026, 1, 1), day(2026, 1, 1)) - isOverride, user := resolveAtTime([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{unavail}, epoch) + isOverride, user := resolveAtTime([]models.ScheduleLayer{layer}, nil, []models.ScheduleUnavailability{unavail}, epoch, time.UTC) if isOverride { t.Error("should not be override") } @@ -526,6 +589,7 @@ func TestResolveAtTime_OverrideWins_EvenWhenUnavailable(t *testing.T) { []models.ScheduleOverride{override}, []models.ScheduleUnavailability{unavail}, epoch.Add(time.Hour), // 01:00 on Jan 1 — inside override window + time.UTC, ) if !isOverride { t.Error("should be marked as override") diff --git a/frontend/src/components/oncall/GanttCalendar.tsx b/frontend/src/components/oncall/GanttCalendar.tsx index ec8117d..dd8c986 100644 --- a/frontend/src/components/oncall/GanttCalendar.tsx +++ b/frontend/src/components/oncall/GanttCalendar.tsx @@ -105,7 +105,7 @@ function getSegmentForDay(segments: TimelineSegment[], day: Date): TimelineSegme const matches = segments.filter((s) => { const segStart = new Date(s.start) const segEnd = new Date(s.end) - return segStart <= dayEnd && segEnd >= dayStart + return segStart < dayEnd && segEnd > dayStart }) // Prefer override segments over regular rotation segments return matches.find((s) => s.is_override) ?? matches[0] ?? null diff --git a/frontend/src/pages/ScheduleDetailPage.tsx b/frontend/src/pages/ScheduleDetailPage.tsx index 58d18ec..20bc041 100644 --- a/frontend/src/pages/ScheduleDetailPage.tsx +++ b/frontend/src/pages/ScheduleDetailPage.tsx @@ -80,6 +80,10 @@ function timeUntil(iso: string): string { return m > 0 ? `${h}h ${m}m` : `${h}h` } +function localDateStr(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + function toScheduleTz(datetimeLocalValue: string, scheduleTz: string): string { const d = new Date(datetimeLocalValue) if (isNaN(d.getTime())) return '' @@ -1147,7 +1151,7 @@ function UnavailabilitiesTable({ scheduleId, unavailabilities, onDeleted, onAdd, } } - const today = new Date().toISOString().slice(0, 10) + const today = localDateStr(new Date()) const statusPill = (u: ScheduleUnavailability) => { if (u.end_date < today) return ( @@ -1389,10 +1393,10 @@ export function ScheduleDetailPage() { // Fetch holidays for the visible window whenever the schedule or month changes. useEffect(() => { if (!id) return - const from = windowStart.toISOString().slice(0, 10) + const from = localDateStr(windowStart) const toDate = new Date(windowStart) toDate.setDate(toDate.getDate() + GANTT_DAYS) - const to = toDate.toISOString().slice(0, 10) + const to = localDateStr(toDate) getHolidays(id, from, to) .then(r => setHolidays(r.data)) .catch(() => {}) @@ -1518,7 +1522,7 @@ export function ScheduleDetailPage() { scheduleId={schedule.id} users={allParticipants} onClose={() => setUnavailModalOpen(false)} - onSaved={() => { toast.success('Unavailability recorded'); refetch() }} + onSaved={() => { toast.success('Unavailability recorded'); invalidateAll() }} /> )} @@ -1592,7 +1596,7 @@ export function ScheduleDetailPage() { )} {(() => { - const todayStr = new Date().toISOString().slice(0, 10) + const todayStr = localDateStr(new Date()) const todayHoliday = holidays.find(h => h.date === todayStr) if (!todayHoliday) return null return (