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.
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"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.goStep 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"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"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"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"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"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"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"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
After completing these tasks, you will have:
ShiftID, AssignmentID,
ResidentID - Type-safe identifiersTimeWindow - Immutable time range with overlap
detectionAssignmentSource - Enum for assignment
attributionShift - Immutable shift with creation from template
supportAssignment - With supersede pattern for history
trackingConstraint interface for extensibilityMaxShiftsConstraint - Limit shifts per residentMinRestHoursConstraint - Ensure minimum rest between
shiftsReconstruct* functions for
persistenceNext Steps (Future Tasks): - Add Template entity with shift generation - Add more constraint types (fairness, preferences) - Add persistence layer (repositories) - Integrate with solver