Call Scheduling Domain Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement call scheduling domain models with tests, following the design in 2026-02-25-call-scheduling-domain-redesign.md.

Architecture: Domain-driven design with immutable entities. Persistence deferred — focus on domain logic and behavior tests only. New package internal/domain/call/ for call-specific domain models.

Tech Stack: Go 1.25+, testify for assertions, existing internal/domain patterns for validation errors.


Task 1: Create Package Structure and Value Objects

Files: - Create: internal/domain/call/shift_id.go - Create: internal/domain/call/shift_id_test.go

Step 1: Write the failing test for ShiftID

// internal/domain/call/shift_id_test.go
package call_test

import (
    "testing"

    "github.com/google/uuid"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/mmeinzer/guava/internal/domain/call"
)

func TestNewShiftID(t *testing.T) {
    t.Parallel()

    id := call.NewShiftID()

    assert.False(t, id.IsZero(), "NewShiftID should not return zero value")
}

func TestShiftIDFromUUID(t *testing.T) {
    t.Parallel()

    u := uuid.New()
    id := call.ShiftIDFromUUID(u)

    assert.Equal(t, u, id.UUID())
}

func TestShiftID_IsZero(t *testing.T) {
    t.Parallel()

    var id call.ShiftID
    assert.True(t, id.IsZero(), "zero value should be zero")
}

func TestShiftID_String(t *testing.T) {
    t.Parallel()

    u := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
    id := call.ShiftIDFromUUID(u)

    assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", id.String())
}

func TestParseShiftID_Valid(t *testing.T) {
    t.Parallel()

    id, err := call.ParseShiftID("550e8400-e29b-41d4-a716-446655440000")
    require.NoError(t, err)

    assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", id.String())
}

func TestParseShiftID_Invalid(t *testing.T) {
    t.Parallel()

    _, err := call.ParseShiftID("not-a-uuid")
    require.Error(t, err)
}

Step 2: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - package does not exist

Step 3: Write minimal implementation

// internal/domain/call/shift_id.go
package call

import "github.com/google/uuid"

// ShiftID uniquely identifies a shift.
type ShiftID struct {
    value uuid.UUID
}

// NewShiftID creates a new unique ShiftID.
func NewShiftID() ShiftID {
    return ShiftID{value: uuid.New()}
}

// ShiftIDFromUUID creates a ShiftID from an existing UUID.
func ShiftIDFromUUID(u uuid.UUID) ShiftID {
    return ShiftID{value: u}
}

// ParseShiftID parses a string into a ShiftID.
func ParseShiftID(s string) (ShiftID, error) {
    u, err := uuid.Parse(s)
    if err != nil {
        return ShiftID{}, err
    }
    return ShiftID{value: u}, nil
}

// UUID returns the underlying UUID.
func (id ShiftID) UUID() uuid.UUID {
    return id.value
}

// String returns the string representation.
func (id ShiftID) String() string {
    return id.value.String()
}

// IsZero reports whether this is the zero value.
func (id ShiftID) IsZero() bool {
    return id.value == uuid.Nil
}

Step 4: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 5: Commit

git add internal/domain/call/
git commit -m "feat(call): add ShiftID value object"

Task 2: Add AssignmentID and ResidentID Value Objects

Files: - Modify: internal/domain/call/shift_id.go (rename to ids.go) - Modify: internal/domain/call/shift_id_test.go (rename to ids_test.go)

Step 1: Rename files and add new ID types

Run:

mv internal/domain/call/shift_id.go internal/domain/call/ids.go
mv internal/domain/call/shift_id_test.go internal/domain/call/ids_test.go

Step 2: Write failing tests for AssignmentID and ResidentID

Add to internal/domain/call/ids_test.go:

func TestNewAssignmentID(t *testing.T) {
    t.Parallel()

    id := call.NewAssignmentID()
    assert.False(t, id.IsZero())
}

func TestAssignmentIDFromUUID(t *testing.T) {
    t.Parallel()

    u := uuid.New()
    id := call.AssignmentIDFromUUID(u)
    assert.Equal(t, u, id.UUID())
}

func TestNewResidentID(t *testing.T) {
    t.Parallel()

    id := call.NewResidentID()
    assert.False(t, id.IsZero())
}

func TestResidentIDFromUUID(t *testing.T) {
    t.Parallel()

    u := uuid.New()
    id := call.ResidentIDFromUUID(u)
    assert.Equal(t, u, id.UUID())
}

Step 3: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - undefined: call.NewAssignmentID

Step 4: Add implementations to ids.go

// AssignmentID uniquely identifies an assignment.
type AssignmentID struct {
    value uuid.UUID
}

// NewAssignmentID creates a new unique AssignmentID.
func NewAssignmentID() AssignmentID {
    return AssignmentID{value: uuid.New()}
}

// AssignmentIDFromUUID creates an AssignmentID from an existing UUID.
func AssignmentIDFromUUID(u uuid.UUID) AssignmentID {
    return AssignmentID{value: u}
}

// UUID returns the underlying UUID.
func (id AssignmentID) UUID() uuid.UUID {
    return id.value
}

// String returns the string representation.
func (id AssignmentID) String() string {
    return id.value.String()
}

// IsZero reports whether this is the zero value.
func (id AssignmentID) IsZero() bool {
    return id.value == uuid.Nil
}

// ResidentID uniquely identifies a resident.
type ResidentID struct {
    value uuid.UUID
}

// NewResidentID creates a new unique ResidentID.
func NewResidentID() ResidentID {
    return ResidentID{value: uuid.New()}
}

// ResidentIDFromUUID creates a ResidentID from an existing UUID.
func ResidentIDFromUUID(u uuid.UUID) ResidentID {
    return ResidentID{value: u}
}

// UUID returns the underlying UUID.
func (id ResidentID) UUID() uuid.UUID {
    return id.value
}

// String returns the string representation.
func (id ResidentID) String() string {
    return id.value.String()
}

// IsZero reports whether this is the zero value.
func (id ResidentID) IsZero() bool {
    return id.value == uuid.Nil
}

Step 5: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 6: Commit

git add internal/domain/call/
git commit -m "feat(call): add AssignmentID and ResidentID value objects"

Task 3: Add TimeWindow Value Object

Files: - Create: internal/domain/call/time_window.go - Create: internal/domain/call/time_window_test.go

Step 1: Write failing tests

// internal/domain/call/time_window_test.go
package call_test

import (
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/mmeinzer/guava/internal/domain/call"
)

func TestNewTimeWindow_Valid(t *testing.T) {
    t.Parallel()

    start := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
    end := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)

    tw, err := call.NewTimeWindow(start, end)
    require.NoError(t, err)

    assert.True(t, tw.StartsAt().Equal(start))
    assert.True(t, tw.EndsAt().Equal(end))
}

func TestNewTimeWindow_EndBeforeStart(t *testing.T) {
    t.Parallel()

    start := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)
    end := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)

    _, err := call.NewTimeWindow(start, end)
    require.Error(t, err)
}

func TestNewTimeWindow_SameTime(t *testing.T) {
    t.Parallel()

    ts := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)

    _, err := call.NewTimeWindow(ts, ts)
    require.Error(t, err, "zero-duration window should be invalid")
}

func TestTimeWindow_Duration(t *testing.T) {
    t.Parallel()

    start := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
    end := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)

    tw, err := call.NewTimeWindow(start, end)
    require.NoError(t, err)

    assert.Equal(t, 12*time.Hour, tw.Duration())
}

func TestTimeWindow_Overlaps(t *testing.T) {
    t.Parallel()

    // Window A: 6pm - 6am
    a, _ := call.NewTimeWindow(
        time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC),
    )

    // Window B: 2am - 10am (overlaps)
    b, _ := call.NewTimeWindow(
        time.Date(2024, 1, 16, 2, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 16, 10, 0, 0, 0, time.UTC),
    )

    // Window C: 10am - 6pm (no overlap)
    c, _ := call.NewTimeWindow(
        time.Date(2024, 1, 16, 10, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 16, 18, 0, 0, 0, time.UTC),
    )

    assert.True(t, a.Overlaps(b), "A and B should overlap")
    assert.True(t, b.Overlaps(a), "B and A should overlap (symmetric)")
    assert.False(t, a.Overlaps(c), "A and C should not overlap")
    assert.False(t, c.Overlaps(a), "C and A should not overlap (symmetric)")
}

func TestTimeWindow_Adjacent_NoOverlap(t *testing.T) {
    t.Parallel()

    // Window A: 6am - 6pm
    a, _ := call.NewTimeWindow(
        time.Date(2024, 1, 15, 6, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC),
    )

    // Window B: 6pm - 6am (adjacent, no overlap)
    b, _ := call.NewTimeWindow(
        time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC),
    )

    assert.False(t, a.Overlaps(b), "adjacent windows should not overlap")
}

Step 2: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - undefined: call.NewTimeWindow

Step 3: Write implementation

// internal/domain/call/time_window.go
package call

import (
    "errors"
    "time"
)

var (
    ErrEndBeforeStart   = errors.New("end time must be after start time")
    ErrZeroDuration     = errors.New("time window must have positive duration")
)

// TimeWindow represents an immutable time range with a start and end timestamp.
// Used for shift schedules. The window is half-open: [start, end).
type TimeWindow struct {
    startsAt time.Time
    endsAt   time.Time
}

// NewTimeWindow creates a TimeWindow from start and end times.
// Returns an error if end is not after start.
func NewTimeWindow(startsAt, endsAt time.Time) (TimeWindow, error) {
    if endsAt.Before(startsAt) {
        return TimeWindow{}, ErrEndBeforeStart
    }
    if endsAt.Equal(startsAt) {
        return TimeWindow{}, ErrZeroDuration
    }
    return TimeWindow{startsAt: startsAt, endsAt: endsAt}, nil
}

// StartsAt returns the start time.
func (tw TimeWindow) StartsAt() time.Time {
    return tw.startsAt
}

// EndsAt returns the end time.
func (tw TimeWindow) EndsAt() time.Time {
    return tw.endsAt
}

// Duration returns the length of the time window.
func (tw TimeWindow) Duration() time.Duration {
    return tw.endsAt.Sub(tw.startsAt)
}

// Overlaps returns true if this window overlaps with another.
// Adjacent windows (one ends exactly when another starts) do not overlap.
func (tw TimeWindow) Overlaps(other TimeWindow) bool {
    return tw.startsAt.Before(other.endsAt) && other.startsAt.Before(tw.endsAt)
}

// IsZero reports whether this is the zero value.
func (tw TimeWindow) IsZero() bool {
    return tw.startsAt.IsZero() && tw.endsAt.IsZero()
}

Step 4: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 5: Commit

git add internal/domain/call/
git commit -m "feat(call): add TimeWindow value object"

Task 4: Add AssignmentSource Enum

Files: - Create: internal/domain/call/assignment_source.go - Create: internal/domain/call/assignment_source_test.go

Step 1: Write failing tests

// internal/domain/call/assignment_source_test.go
package call_test

import (
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/mmeinzer/guava/internal/domain/call"
)

func TestAssignmentSource_String(t *testing.T) {
    t.Parallel()

    tests := []struct {
        source call.AssignmentSource
        want   string
    }{
        {call.AssignmentSourceSolver, "solver"},
        {call.AssignmentSourceManual, "manual"},
        {call.AssignmentSourceSwap, "swap"},
        {call.AssignmentSourceCoverage, "coverage"},
        {call.AssignmentSourceReassign, "reassign"},
    }

    for _, tt := range tests {
        t.Run(tt.want, func(t *testing.T) {
            t.Parallel()
            assert.Equal(t, tt.want, tt.source.String())
        })
    }
}

func TestParseAssignmentSource_Valid(t *testing.T) {
    t.Parallel()

    tests := []struct {
        input string
        want  call.AssignmentSource
    }{
        {"solver", call.AssignmentSourceSolver},
        {"manual", call.AssignmentSourceManual},
        {"swap", call.AssignmentSourceSwap},
        {"coverage", call.AssignmentSourceCoverage},
        {"reassign", call.AssignmentSourceReassign},
    }

    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            t.Parallel()
            got, err := call.ParseAssignmentSource(tt.input)
            require.NoError(t, err)
            assert.Equal(t, tt.want, got)
        })
    }
}

func TestParseAssignmentSource_Invalid(t *testing.T) {
    t.Parallel()

    _, err := call.ParseAssignmentSource("unknown")
    require.Error(t, err)
}

func TestAssignmentSource_IsValid(t *testing.T) {
    t.Parallel()

    assert.True(t, call.AssignmentSourceSolver.IsValid())
    assert.True(t, call.AssignmentSourceManual.IsValid())

    var invalid call.AssignmentSource
    assert.False(t, invalid.IsValid())
}

Step 2: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - undefined: call.AssignmentSourceSolver

Step 3: Write implementation

// internal/domain/call/assignment_source.go
package call

import "fmt"

// AssignmentSource indicates how an assignment was created.
type AssignmentSource int

const (
    AssignmentSourceUnknown AssignmentSource = iota
    AssignmentSourceSolver                   // Promoted from solver proposal
    AssignmentSourceManual                   // Admin created directly
    AssignmentSourceSwap                     // Result of resident swap
    AssignmentSourceCoverage                 // Someone covering for another
    AssignmentSourceReassign                 // Plan changed before shift
)

var assignmentSourceStrings = map[AssignmentSource]string{
    AssignmentSourceSolver:   "solver",
    AssignmentSourceManual:   "manual",
    AssignmentSourceSwap:     "swap",
    AssignmentSourceCoverage: "coverage",
    AssignmentSourceReassign: "reassign",
}

var stringToAssignmentSource = map[string]AssignmentSource{
    "solver":   AssignmentSourceSolver,
    "manual":   AssignmentSourceManual,
    "swap":     AssignmentSourceSwap,
    "coverage": AssignmentSourceCoverage,
    "reassign": AssignmentSourceReassign,
}

// String returns the string representation.
func (s AssignmentSource) String() string {
    if str, ok := assignmentSourceStrings[s]; ok {
        return str
    }
    return "unknown"
}

// ParseAssignmentSource parses a string into an AssignmentSource.
func ParseAssignmentSource(str string) (AssignmentSource, error) {
    if source, ok := stringToAssignmentSource[str]; ok {
        return source, nil
    }
    return AssignmentSourceUnknown, fmt.Errorf("unknown assignment source: %s", str)
}

// IsValid reports whether this is a valid (non-unknown) source.
func (s AssignmentSource) IsValid() bool {
    _, ok := assignmentSourceStrings[s]
    return ok
}

Step 4: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 5: Commit

git add internal/domain/call/
git commit -m "feat(call): add AssignmentSource enum"

Task 5: Create Shift Entity

Files: - Create: internal/domain/call/shift.go - Create: internal/domain/call/shift_test.go

Step 1: Write failing tests

// internal/domain/call/shift_test.go
package call_test

import (
    "testing"
    "time"

    "github.com/google/uuid"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/mmeinzer/guava/internal/domain/call"
)

func TestNewShift(t *testing.T) {
    t.Parallel()

    programID := uuid.New()
    siteID := uuid.New()
    callTypeID := uuid.New()
    startsAt := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
    endsAt := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)

    shift, err := call.NewShift(programID, siteID, callTypeID, startsAt, endsAt)
    require.NoError(t, err)

    assert.False(t, shift.ID().IsZero())
    assert.Equal(t, programID, shift.ProgramID())
    assert.Equal(t, siteID, shift.SiteID())
    assert.Equal(t, callTypeID, shift.CallTypeID())
    assert.True(t, shift.StartsAt().Equal(startsAt))
    assert.True(t, shift.EndsAt().Equal(endsAt))
    assert.Nil(t, shift.TemplateID())
    assert.False(t, shift.CreatedAt().IsZero())
}

func TestNewShift_WithTemplate(t *testing.T) {
    t.Parallel()

    programID := uuid.New()
    siteID := uuid.New()
    callTypeID := uuid.New()
    templateID := uuid.New()
    startsAt := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
    endsAt := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)

    shift, err := call.NewShiftFromTemplate(programID, siteID, callTypeID, templateID, startsAt, endsAt)
    require.NoError(t, err)

    require.NotNil(t, shift.TemplateID())
    assert.Equal(t, templateID, *shift.TemplateID())
}

func TestNewShift_InvalidTimeWindow(t *testing.T) {
    t.Parallel()

    programID := uuid.New()
    siteID := uuid.New()
    callTypeID := uuid.New()
    startsAt := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)
    endsAt := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC) // Before start

    _, err := call.NewShift(programID, siteID, callTypeID, startsAt, endsAt)
    require.Error(t, err)
}

func TestShift_TimeWindow(t *testing.T) {
    t.Parallel()

    programID := uuid.New()
    siteID := uuid.New()
    callTypeID := uuid.New()
    startsAt := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
    endsAt := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)

    shift, err := call.NewShift(programID, siteID, callTypeID, startsAt, endsAt)
    require.NoError(t, err)

    tw := shift.TimeWindow()
    assert.True(t, tw.StartsAt().Equal(startsAt))
    assert.True(t, tw.EndsAt().Equal(endsAt))
    assert.Equal(t, 12*time.Hour, tw.Duration())
}

func TestReconstructShift(t *testing.T) {
    t.Parallel()

    id := call.NewShiftID()
    programID := uuid.New()
    siteID := uuid.New()
    callTypeID := uuid.New()
    templateID := uuid.New()
    startsAt := time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC)
    endsAt := time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC)
    createdAt := time.Now().Add(-24 * time.Hour)

    shift := call.ReconstructShift(id, programID, siteID, callTypeID, &templateID, startsAt, endsAt, createdAt)

    assert.Equal(t, id, shift.ID())
    assert.Equal(t, programID, shift.ProgramID())
    require.NotNil(t, shift.TemplateID())
    assert.Equal(t, templateID, *shift.TemplateID())
    assert.True(t, shift.CreatedAt().Equal(createdAt))
}

Step 2: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - undefined: call.NewShift

Step 3: Write implementation

// internal/domain/call/shift.go
package call

import (
    "time"

    "github.com/google/uuid"
)

// Shift represents an immutable call shift.
// Once created, a shift cannot be modified.
type Shift struct {
    id         ShiftID
    programID  uuid.UUID
    siteID     uuid.UUID
    callTypeID uuid.UUID
    templateID *uuid.UUID // nil for manually created shifts
    timeWindow TimeWindow
    createdAt  time.Time
}

// NewShift creates a new manual shift (not from a template).
func NewShift(programID, siteID, callTypeID uuid.UUID, startsAt, endsAt time.Time) (*Shift, error) {
    tw, err := NewTimeWindow(startsAt, endsAt)
    if err != nil {
        return nil, err
    }

    return &Shift{
        id:         NewShiftID(),
        programID:  programID,
        siteID:     siteID,
        callTypeID: callTypeID,
        templateID: nil,
        timeWindow: tw,
        createdAt:  time.Now(),
    }, nil
}

// NewShiftFromTemplate creates a new shift generated from a template.
func NewShiftFromTemplate(programID, siteID, callTypeID, templateID uuid.UUID, startsAt, endsAt time.Time) (*Shift, error) {
    tw, err := NewTimeWindow(startsAt, endsAt)
    if err != nil {
        return nil, err
    }

    return &Shift{
        id:         NewShiftID(),
        programID:  programID,
        siteID:     siteID,
        callTypeID: callTypeID,
        templateID: &templateID,
        timeWindow: tw,
        createdAt:  time.Now(),
    }, nil
}

// ReconstructShift recreates a Shift from persisted data.
// Used by repositories when loading from database.
func ReconstructShift(id ShiftID, programID, siteID, callTypeID uuid.UUID, templateID *uuid.UUID, startsAt, endsAt, createdAt time.Time) *Shift {
    // Bypass validation since data is from trusted source (database)
    return &Shift{
        id:         id,
        programID:  programID,
        siteID:     siteID,
        callTypeID: callTypeID,
        templateID: templateID,
        timeWindow: TimeWindow{startsAt: startsAt, endsAt: endsAt},
        createdAt:  createdAt,
    }
}

// ID returns the shift's unique identifier.
func (s *Shift) ID() ShiftID {
    return s.id
}

// ProgramID returns the program this shift belongs to.
func (s *Shift) ProgramID() uuid.UUID {
    return s.programID
}

// SiteID returns the site where this shift takes place.
func (s *Shift) SiteID() uuid.UUID {
    return s.siteID
}

// CallTypeID returns the type of call (e.g., Night Float, ICU).
func (s *Shift) CallTypeID() uuid.UUID {
    return s.callTypeID
}

// TemplateID returns the template this shift was generated from, or nil if manual.
func (s *Shift) TemplateID() *uuid.UUID {
    return s.templateID
}

// StartsAt returns when the shift starts.
func (s *Shift) StartsAt() time.Time {
    return s.timeWindow.StartsAt()
}

// EndsAt returns when the shift ends.
func (s *Shift) EndsAt() time.Time {
    return s.timeWindow.EndsAt()
}

// TimeWindow returns the shift's time window.
func (s *Shift) TimeWindow() TimeWindow {
    return s.timeWindow
}

// CreatedAt returns when the shift was created.
func (s *Shift) CreatedAt() time.Time {
    return s.createdAt
}

Step 4: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 5: Commit

git add internal/domain/call/
git commit -m "feat(call): add Shift entity"

Task 6: Create Assignment Entity with Supersede Pattern

Files: - Create: internal/domain/call/assignment.go - Create: internal/domain/call/assignment_test.go

Step 1: Write failing tests

// internal/domain/call/assignment_test.go
package call_test

import (
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"

    "github.com/mmeinzer/guava/internal/domain/call"
)

func TestNewAssignment(t *testing.T) {
    t.Parallel()

    shiftID := call.NewShiftID()
    residentID := call.NewResidentID()
    source := call.AssignmentSourceManual

    assignment := call.NewAssignment(shiftID, residentID, source)

    assert.False(t, assignment.ID().IsZero())
    assert.Equal(t, shiftID, assignment.ShiftID())
    assert.Equal(t, residentID, assignment.ResidentID())
    assert.Equal(t, source, assignment.Source())
    assert.False(t, assignment.CreatedAt().IsZero())
    assert.True(t, assignment.IsCurrent(), "new assignment should be current")
    assert.Nil(t, assignment.SupersededAt())
}

func TestAssignment_Supersede(t *testing.T) {
    t.Parallel()

    shiftID := call.NewShiftID()
    residentID := call.NewResidentID()
    source := call.AssignmentSourceManual

    assignment := call.NewAssignment(shiftID, residentID, source)
    require.True(t, assignment.IsCurrent())

    err := assignment.Supersede()
    require.NoError(t, err)

    assert.False(t, assignment.IsCurrent(), "superseded assignment should not be current")
    assert.NotNil(t, assignment.SupersededAt())
}

func TestAssignment_Supersede_AlreadySuperseded(t *testing.T) {
    t.Parallel()

    shiftID := call.NewShiftID()
    residentID := call.NewResidentID()
    source := call.AssignmentSourceManual

    assignment := call.NewAssignment(shiftID, residentID, source)
    err := assignment.Supersede()
    require.NoError(t, err)

    // Try to supersede again
    err = assignment.Supersede()
    require.Error(t, err, "should not be able to supersede twice")
}

func TestReconstructAssignment(t *testing.T) {
    t.Parallel()

    id := call.NewAssignmentID()
    shiftID := call.NewShiftID()
    residentID := call.NewResidentID()
    source := call.AssignmentSourceSolver
    createdAt := time.Now().Add(-24 * time.Hour)
    supersededAt := time.Now().Add(-1 * time.Hour)

    assignment := call.ReconstructAssignment(id, shiftID, residentID, source, createdAt, &supersededAt)

    assert.Equal(t, id, assignment.ID())
    assert.Equal(t, shiftID, assignment.ShiftID())
    assert.Equal(t, residentID, assignment.ResidentID())
    assert.Equal(t, source, assignment.Source())
    assert.True(t, assignment.CreatedAt().Equal(createdAt))
    assert.False(t, assignment.IsCurrent())
    require.NotNil(t, assignment.SupersededAt())
    assert.True(t, assignment.SupersededAt().Equal(supersededAt))
}

func TestReconstructAssignment_Current(t *testing.T) {
    t.Parallel()

    id := call.NewAssignmentID()
    shiftID := call.NewShiftID()
    residentID := call.NewResidentID()
    source := call.AssignmentSourceSolver
    createdAt := time.Now().Add(-24 * time.Hour)

    assignment := call.ReconstructAssignment(id, shiftID, residentID, source, createdAt, nil)

    assert.True(t, assignment.IsCurrent())
    assert.Nil(t, assignment.SupersededAt())
}

Step 2: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - undefined: call.NewAssignment

Step 3: Write implementation

// internal/domain/call/assignment.go
package call

import (
    "errors"
    "time"
)

var ErrAlreadySuperseded = errors.New("assignment already superseded")

// Assignment represents an assignment of a resident to a shift.
// Assignments are immutable except for the supersede operation.
type Assignment struct {
    id           AssignmentID
    shiftID      ShiftID
    residentID   ResidentID
    source       AssignmentSource
    createdAt    time.Time
    supersededAt *time.Time
}

// NewAssignment creates a new current assignment.
func NewAssignment(shiftID ShiftID, residentID ResidentID, source AssignmentSource) *Assignment {
    return &Assignment{
        id:           NewAssignmentID(),
        shiftID:      shiftID,
        residentID:   residentID,
        source:       source,
        createdAt:    time.Now(),
        supersededAt: nil,
    }
}

// ReconstructAssignment recreates an Assignment from persisted data.
// Used by repositories when loading from database.
func ReconstructAssignment(id AssignmentID, shiftID ShiftID, residentID ResidentID, source AssignmentSource, createdAt time.Time, supersededAt *time.Time) *Assignment {
    return &Assignment{
        id:           id,
        shiftID:      shiftID,
        residentID:   residentID,
        source:       source,
        createdAt:    createdAt,
        supersededAt: supersededAt,
    }
}

// ID returns the assignment's unique identifier.
func (a *Assignment) ID() AssignmentID {
    return a.id
}

// ShiftID returns the shift this assignment is for.
func (a *Assignment) ShiftID() ShiftID {
    return a.shiftID
}

// ResidentID returns the resident assigned to the shift.
func (a *Assignment) ResidentID() ResidentID {
    return a.residentID
}

// Source returns how this assignment was created.
func (a *Assignment) Source() AssignmentSource {
    return a.source
}

// CreatedAt returns when this assignment was created.
func (a *Assignment) CreatedAt() time.Time {
    return a.createdAt
}

// SupersededAt returns when this assignment was superseded, or nil if current.
func (a *Assignment) SupersededAt() *time.Time {
    return a.supersededAt
}

// IsCurrent returns true if this assignment is currently active (not superseded).
func (a *Assignment) IsCurrent() bool {
    return a.supersededAt == nil
}

// Supersede marks this assignment as no longer current.
// Returns an error if already superseded.
func (a *Assignment) Supersede() error {
    if a.supersededAt != nil {
        return ErrAlreadySuperseded
    }
    now := time.Now()
    a.supersededAt = &now
    return nil
}

Step 4: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 5: Commit

git add internal/domain/call/
git commit -m "feat(call): add Assignment entity with supersede pattern"

Task 7: Add Constraint Interface

Files: - Create: internal/domain/call/constraint.go - Create: internal/domain/call/constraint_test.go

Step 1: Write failing tests for constraint interface and a simple implementation

// internal/domain/call/constraint_test.go
package call_test

import (
    "testing"

    "github.com/stretchr/testify/assert"

    "github.com/mmeinzer/guava/internal/domain/call"
)

func TestMaxShiftsPerPeriodConstraint_IsSatisfied(t *testing.T) {
    t.Parallel()

    // Constraint: max 2 shifts per resident
    constraint := call.NewMaxShiftsConstraint(2)

    residentID := call.NewResidentID()
    shiftID1 := call.NewShiftID()
    shiftID2 := call.NewShiftID()
    shiftID3 := call.NewShiftID()

    // Build assignment context
    existing := []*call.Assignment{
        call.NewAssignment(shiftID1, residentID, call.AssignmentSourceManual),
        call.NewAssignment(shiftID2, residentID, call.AssignmentSourceManual),
    }

    // Try to add a third assignment
    proposed := call.NewAssignment(shiftID3, residentID, call.AssignmentSourceManual)

    ctx := call.ConstraintContext{
        ExistingAssignments: existing,
    }

    result := constraint.Check(proposed, ctx)
    assert.False(t, result.Satisfied, "should not allow 3rd shift when max is 2")
    assert.NotEmpty(t, result.Reason)
}

func TestMaxShiftsPerPeriodConstraint_IsSatisfied_UnderLimit(t *testing.T) {
    t.Parallel()

    constraint := call.NewMaxShiftsConstraint(3)

    residentID := call.NewResidentID()
    shiftID1 := call.NewShiftID()
    shiftID2 := call.NewShiftID()
    shiftID3 := call.NewShiftID()

    existing := []*call.Assignment{
        call.NewAssignment(shiftID1, residentID, call.AssignmentSourceManual),
        call.NewAssignment(shiftID2, residentID, call.AssignmentSourceManual),
    }

    proposed := call.NewAssignment(shiftID3, residentID, call.AssignmentSourceManual)

    ctx := call.ConstraintContext{
        ExistingAssignments: existing,
    }

    result := constraint.Check(proposed, ctx)
    assert.True(t, result.Satisfied, "should allow 3rd shift when max is 3")
}

func TestConstraint_IsHard(t *testing.T) {
    t.Parallel()

    hardConstraint := call.NewMaxShiftsConstraint(2)
    assert.True(t, hardConstraint.IsHard())
}

Step 2: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - undefined: call.NewMaxShiftsConstraint

Step 3: Write implementation

// internal/domain/call/constraint.go
package call

import "fmt"

// ConstraintResult holds the outcome of a constraint check.
type ConstraintResult struct {
    Satisfied bool
    Reason    string // Human-readable explanation when not satisfied
}

// ConstraintContext provides context for constraint evaluation.
type ConstraintContext struct {
    ExistingAssignments []*Assignment
    // Future: add shifts, residents, time periods, etc.
}

// Constraint defines the interface for assignment constraints.
type Constraint interface {
    // Check evaluates whether a proposed assignment satisfies this constraint.
    Check(proposed *Assignment, ctx ConstraintContext) ConstraintResult

    // IsHard returns true if this is a hard constraint (must be satisfied),
    // false if it's a soft constraint (optimization target).
    IsHard() bool

    // Weight returns the penalty weight for soft constraints.
    // Returns 0 for hard constraints.
    Weight() int
}

// MaxShiftsConstraint limits the number of shifts per resident.
type MaxShiftsConstraint struct {
    maxShifts int
}

// NewMaxShiftsConstraint creates a constraint limiting shifts per resident.
func NewMaxShiftsConstraint(maxShifts int) *MaxShiftsConstraint {
    return &MaxShiftsConstraint{maxShifts: maxShifts}
}

// Check evaluates whether adding the proposed assignment would exceed the limit.
func (c *MaxShiftsConstraint) Check(proposed *Assignment, ctx ConstraintContext) ConstraintResult {
    // Count existing assignments for this resident
    count := 0
    for _, a := range ctx.ExistingAssignments {
        if a.ResidentID() == proposed.ResidentID() && a.IsCurrent() {
            count++
        }
    }

    // Adding the proposed would make count+1
    if count+1 > c.maxShifts {
        return ConstraintResult{
            Satisfied: false,
            Reason:    fmt.Sprintf("resident would have %d shifts, exceeding max of %d", count+1, c.maxShifts),
        }
    }

    return ConstraintResult{Satisfied: true}
}

// IsHard returns true - this is a hard constraint.
func (c *MaxShiftsConstraint) IsHard() bool {
    return true
}

// Weight returns 0 for hard constraints.
func (c *MaxShiftsConstraint) Weight() int {
    return 0
}

Step 4: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 5: Commit

git add internal/domain/call/
git commit -m "feat(call): add Constraint interface and MaxShiftsConstraint"

Task 8: Add MinRestHoursConstraint

Files: - Modify: internal/domain/call/constraint.go - Modify: internal/domain/call/constraint_test.go

Step 1: Write failing tests

Add to internal/domain/call/constraint_test.go:

func TestMinRestHoursConstraint_Violated(t *testing.T) {
    t.Parallel()

    // Constraint: min 10 hours rest between shifts
    constraint := call.NewMinRestHoursConstraint(10)

    residentID := call.NewResidentID()

    // Existing shift: Jan 15 6pm - Jan 16 6am
    existingShift, _ := call.NewShift(
        uuid.New(), uuid.New(), uuid.New(),
        time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC),
    )
    existingAssignment := call.NewAssignment(existingShift.ID(), residentID, call.AssignmentSourceManual)

    // Proposed shift: Jan 16 10am - 6pm (only 4 hours after existing ends)
    proposedShift, _ := call.NewShift(
        uuid.New(), uuid.New(), uuid.New(),
        time.Date(2024, 1, 16, 10, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 16, 18, 0, 0, 0, time.UTC),
    )
    proposed := call.NewAssignment(proposedShift.ID(), residentID, call.AssignmentSourceManual)

    ctx := call.ConstraintContext{
        ExistingAssignments: []*call.Assignment{existingAssignment},
        Shifts:              map[call.ShiftID]*call.Shift{
            existingShift.ID(): existingShift,
            proposedShift.ID(): proposedShift,
        },
    }

    result := constraint.Check(proposed, ctx)
    assert.False(t, result.Satisfied, "should not allow shift with only 4h rest when min is 10h")
    assert.Contains(t, result.Reason, "rest")
}

func TestMinRestHoursConstraint_Satisfied(t *testing.T) {
    t.Parallel()

    constraint := call.NewMinRestHoursConstraint(10)

    residentID := call.NewResidentID()

    // Existing shift: Jan 15 6pm - Jan 16 6am
    existingShift, _ := call.NewShift(
        uuid.New(), uuid.New(), uuid.New(),
        time.Date(2024, 1, 15, 18, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 16, 6, 0, 0, 0, time.UTC),
    )
    existingAssignment := call.NewAssignment(existingShift.ID(), residentID, call.AssignmentSourceManual)

    // Proposed shift: Jan 16 6pm - Jan 17 6am (12 hours after existing ends)
    proposedShift, _ := call.NewShift(
        uuid.New(), uuid.New(), uuid.New(),
        time.Date(2024, 1, 16, 18, 0, 0, 0, time.UTC),
        time.Date(2024, 1, 17, 6, 0, 0, 0, time.UTC),
    )
    proposed := call.NewAssignment(proposedShift.ID(), residentID, call.AssignmentSourceManual)

    ctx := call.ConstraintContext{
        ExistingAssignments: []*call.Assignment{existingAssignment},
        Shifts: map[call.ShiftID]*call.Shift{
            existingShift.ID(): existingShift,
            proposedShift.ID(): proposedShift,
        },
    }

    result := constraint.Check(proposed, ctx)
    assert.True(t, result.Satisfied, "should allow shift with 12h rest when min is 10h")
}

Step 2: Run test to verify it fails

Run: go test ./internal/domain/call/... -v Expected: FAIL - undefined: call.NewMinRestHoursConstraint

Step 3: Update ConstraintContext and add MinRestHoursConstraint

First update ConstraintContext in constraint.go:

// ConstraintContext provides context for constraint evaluation.
type ConstraintContext struct {
    ExistingAssignments []*Assignment
    Shifts              map[ShiftID]*Shift // Lookup for shift details
}

Then add the constraint:

// MinRestHoursConstraint ensures minimum rest between shifts.
type MinRestHoursConstraint struct {
    minHours int
}

// NewMinRestHoursConstraint creates a constraint requiring minimum rest hours.
func NewMinRestHoursConstraint(minHours int) *MinRestHoursConstraint {
    return &MinRestHoursConstraint{minHours: minHours}
}

// Check evaluates whether the proposed assignment violates rest requirements.
func (c *MinRestHoursConstraint) Check(proposed *Assignment, ctx ConstraintContext) ConstraintResult {
    proposedShift, ok := ctx.Shifts[proposed.ShiftID()]
    if !ok {
        return ConstraintResult{Satisfied: true} // Can't check without shift data
    }

    minRest := time.Duration(c.minHours) * time.Hour

    for _, existing := range ctx.ExistingAssignments {
        if existing.ResidentID() != proposed.ResidentID() || !existing.IsCurrent() {
            continue
        }

        existingShift, ok := ctx.Shifts[existing.ShiftID()]
        if !ok {
            continue
        }

        // Check rest between existing end and proposed start
        if proposedShift.StartsAt().After(existingShift.EndsAt()) {
            gap := proposedShift.StartsAt().Sub(existingShift.EndsAt())
            if gap < minRest {
                return ConstraintResult{
                    Satisfied: false,
                    Reason: fmt.Sprintf("only %.1f hours rest between shifts, minimum is %d hours",
                        gap.Hours(), c.minHours),
                }
            }
        }

        // Check rest between proposed end and existing start
        if existingShift.StartsAt().After(proposedShift.EndsAt()) {
            gap := existingShift.StartsAt().Sub(proposedShift.EndsAt())
            if gap < minRest {
                return ConstraintResult{
                    Satisfied: false,
                    Reason: fmt.Sprintf("only %.1f hours rest between shifts, minimum is %d hours",
                        gap.Hours(), c.minHours),
                }
            }
        }
    }

    return ConstraintResult{Satisfied: true}
}

// IsHard returns true - this is a hard constraint (ACGME requirement).
func (c *MinRestHoursConstraint) IsHard() bool {
    return true
}

// Weight returns 0 for hard constraints.
func (c *MinRestHoursConstraint) Weight() int {
    return 0
}

Step 4: Update imports in test file

Add "github.com/google/uuid" to imports in constraint_test.go.

Step 5: Run test to verify it passes

Run: go test ./internal/domain/call/... -v Expected: PASS

Step 6: Commit

git add internal/domain/call/
git commit -m "feat(call): add MinRestHoursConstraint"

Task 9: Run Full Test Suite and Verify

Step 1: Run all domain tests

Run: go test ./internal/domain/... -v Expected: All PASS

Step 2: Run linter

Run: make lint-go Expected: No errors

Step 3: Commit any fixes if needed


Summary

After completing these tasks, you will have:

  1. Value Objects:
  2. Entities:
  3. Constraints:
  4. Patterns Established:

Next Steps (Future Tasks): - Add Template entity with shift generation - Add more constraint types (fairness, preferences) - Add persistence layer (repositories) - Integrate with solver