Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
frontend/node_modules
frontend/dist
backend/tmp
backend/bin
backend/ui/dist
.git
.claude
*.local
.env
.env.*
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ yarn-error.log*

# Docker
*.pid
.dockerignore

# OS
.DS_Store
Expand Down
4 changes: 2 additions & 2 deletions backend/internal/api/handlers/dto/schedule_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/api/handlers/dto/schedule_response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}
Expand Down
62 changes: 62 additions & 0 deletions backend/internal/models/date_only.go
Original file line number Diff line number Diff line change
@@ -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"))
}
110 changes: 110 additions & 0 deletions backend/internal/models/date_only_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 3 additions & 3 deletions backend/internal/models/schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
}
Expand Down
6 changes: 2 additions & 4 deletions backend/internal/repository/schedule_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
29 changes: 12 additions & 17 deletions backend/internal/repository/unavailability_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/services/holiday_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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:"):
Expand Down
7 changes: 2 additions & 5 deletions backend/internal/services/holiday_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Loading
Loading