Replace status_id with in_progress boolean field
- Remove task_statuses lookup table and StatusID foreign key - Add InProgress boolean field to Task model - Add database migration (005_replace_status_with_in_progress) - Update all handlers, services, and repositories - Update admin frontend to display in_progress as checkbox/boolean - Remove Task Statuses tab from admin lookups page - Update tests to use InProgress instead of StatusID - Task categorization now uses InProgress for kanban column assignment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,6 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) {
|
||||
yesterday := now.AddDate(0, 0, -1)
|
||||
in5Days := now.AddDate(0, 0, 5)
|
||||
in60Days := now.AddDate(0, 0, 60)
|
||||
inProgressStatus := &models.TaskStatus{Name: "In Progress"}
|
||||
daysThreshold := 30
|
||||
|
||||
tests := []struct {
|
||||
@@ -32,7 +31,7 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) {
|
||||
task: &models.Task{
|
||||
IsCancelled: true,
|
||||
NextDueDate: timePtr(yesterday), // Would be overdue
|
||||
Status: inProgressStatus, // Would be in progress
|
||||
InProgress: true, // Would be in progress
|
||||
Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}, // Would be completed if NextDueDate was nil
|
||||
},
|
||||
expected: categorization.ColumnCancelled,
|
||||
@@ -68,7 +67,7 @@ func TestCategorizeTask_PriorityOrder(t *testing.T) {
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
NextDueDate: timePtr(yesterday), // Would be overdue
|
||||
Status: inProgressStatus,
|
||||
InProgress: true,
|
||||
Completions: []models.TaskCompletion{},
|
||||
},
|
||||
expected: categorization.ColumnInProgress,
|
||||
@@ -151,13 +150,13 @@ func TestCategorizeTasksIntoColumns(t *testing.T) {
|
||||
daysThreshold := 30
|
||||
|
||||
tasks := []models.Task{
|
||||
{BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled
|
||||
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed
|
||||
{BaseModel: models.BaseModel{ID: 3}, Status: &models.TaskStatus{Name: "In Progress"}}, // In Progress
|
||||
{BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue
|
||||
{BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon
|
||||
{BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming
|
||||
{BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date)
|
||||
{BaseModel: models.BaseModel{ID: 1}, IsCancelled: true}, // Cancelled
|
||||
{BaseModel: models.BaseModel{ID: 2}, NextDueDate: nil, Completions: []models.TaskCompletion{{BaseModel: models.BaseModel{ID: 1}}}}, // Completed
|
||||
{BaseModel: models.BaseModel{ID: 3}, InProgress: true}, // In Progress
|
||||
{BaseModel: models.BaseModel{ID: 4}, NextDueDate: timePtr(yesterday)}, // Overdue
|
||||
{BaseModel: models.BaseModel{ID: 5}, NextDueDate: timePtr(in5Days)}, // Due Soon
|
||||
{BaseModel: models.BaseModel{ID: 6}, NextDueDate: timePtr(in60Days)}, // Upcoming
|
||||
{BaseModel: models.BaseModel{ID: 7}}, // Upcoming (no due date)
|
||||
}
|
||||
|
||||
result := categorization.CategorizeTasksIntoColumns(tasks, daysThreshold)
|
||||
|
||||
@@ -103,15 +103,6 @@ func createCompletion(t *testing.T, taskID uint) {
|
||||
}
|
||||
}
|
||||
|
||||
// getInProgressStatusID returns the ID of the "In Progress" status
|
||||
func getInProgressStatusID(t *testing.T) *uint {
|
||||
var status models.TaskStatus
|
||||
if err := testDB.Where("name = ?", "In Progress").First(&status).Error; err != nil {
|
||||
t.Logf("In Progress status not found, skipping in-progress tests")
|
||||
return nil
|
||||
}
|
||||
return &status.ID
|
||||
}
|
||||
|
||||
// TaskTestCase defines a test scenario with expected categorization
|
||||
type TaskTestCase struct {
|
||||
@@ -147,8 +138,6 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
||||
in60Days := now.AddDate(0, 0, 60)
|
||||
daysThreshold := 30
|
||||
|
||||
inProgressStatusID := getInProgressStatusID(t)
|
||||
|
||||
// Define all test cases with expected results for each layer
|
||||
testCases := []TaskTestCase{
|
||||
{
|
||||
@@ -293,27 +282,23 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
||||
ExpectDueSoon: false,
|
||||
ExpectUpcoming: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Add in-progress test case only if status exists
|
||||
if inProgressStatusID != nil {
|
||||
testCases = append(testCases, TaskTestCase{
|
||||
{
|
||||
Name: "in_progress_overdue",
|
||||
Task: &models.Task{
|
||||
Title: "in_progress_overdue",
|
||||
NextDueDate: timePtr(yesterday), // Would be overdue
|
||||
StatusID: inProgressStatusID,
|
||||
InProgress: true,
|
||||
IsCancelled: false,
|
||||
IsArchived: false,
|
||||
},
|
||||
ExpectedColumn: categorization.ColumnInProgress, // In Progress takes priority
|
||||
ExpectCompleted: false,
|
||||
ExpectActive: true,
|
||||
ExpectOverdue: true, // Predicate says overdue (doesn't check status)
|
||||
ExpectOverdue: true, // Predicate says overdue (doesn't check InProgress)
|
||||
ExpectDueSoon: false,
|
||||
ExpectUpcoming: false,
|
||||
ExpectInProgress: true,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Create all tasks in database
|
||||
@@ -330,7 +315,6 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
||||
var allTasks []models.Task
|
||||
err := testDB.
|
||||
Preload("Completions").
|
||||
Preload("Status").
|
||||
Where("residence_id = ?", residenceID).
|
||||
Find(&allTasks).Error
|
||||
if err != nil {
|
||||
@@ -490,26 +474,24 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// Test ScopeInProgress (if status exists)
|
||||
if inProgressStatusID != nil {
|
||||
t.Run("ScopeInProgress", func(t *testing.T) {
|
||||
var scopeResults []models.Task
|
||||
testDB.Model(&models.Task{}).
|
||||
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
|
||||
Find(&scopeResults)
|
||||
// Test ScopeInProgress
|
||||
t.Run("ScopeInProgress", func(t *testing.T) {
|
||||
var scopeResults []models.Task
|
||||
testDB.Model(&models.Task{}).
|
||||
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeInProgress).
|
||||
Find(&scopeResults)
|
||||
|
||||
predicateCount := 0
|
||||
for _, task := range allTasks {
|
||||
if predicates.IsInProgress(&task) {
|
||||
predicateCount++
|
||||
}
|
||||
predicateCount := 0
|
||||
for _, task := range allTasks {
|
||||
if predicates.IsInProgress(&task) {
|
||||
predicateCount++
|
||||
}
|
||||
}
|
||||
|
||||
if len(scopeResults) != predicateCount {
|
||||
t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(scopeResults) != predicateCount {
|
||||
t.Errorf("ScopeInProgress returned %d, predicates found %d", len(scopeResults), predicateCount)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ========== TEST CATEGORIZATION MATCHES SCOPES FOR KANBAN ==========
|
||||
@@ -527,7 +509,6 @@ func TestAllThreeLayersMatch(t *testing.T) {
|
||||
t.Run("overdue_column", func(t *testing.T) {
|
||||
var scopeResults []models.Task
|
||||
testDB.Model(&models.Task{}).
|
||||
Preload("Status").
|
||||
Scopes(scopes.ScopeForResidence(residenceID), scopes.ScopeOverdue(now)).
|
||||
Find(&scopeResults)
|
||||
|
||||
@@ -612,7 +593,7 @@ func TestSameDayOverdueConsistency(t *testing.T) {
|
||||
|
||||
// Reload with preloads
|
||||
var loadedTask models.Task
|
||||
testDB.Preload("Completions").Preload("Status").First(&loadedTask, task.ID)
|
||||
testDB.Preload("Completions").First(&loadedTask, task.ID)
|
||||
|
||||
// All three layers should agree
|
||||
predicateResult := predicates.IsOverdue(&loadedTask, now)
|
||||
|
||||
@@ -60,13 +60,13 @@ func IsArchived(task *models.Task) bool {
|
||||
return task.IsArchived
|
||||
}
|
||||
|
||||
// IsInProgress returns true if the task has status "In Progress".
|
||||
// IsInProgress returns true if the task is marked as in progress.
|
||||
//
|
||||
// SQL equivalent (in scopes.go ScopeInProgress):
|
||||
//
|
||||
// task_taskstatus.name = 'In Progress'
|
||||
// in_progress = true
|
||||
func IsInProgress(task *models.Task) bool {
|
||||
return task.Status != nil && task.Status.Name == "In Progress"
|
||||
return task.InProgress
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -102,27 +102,19 @@ func TestIsActive(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsInProgress(t *testing.T) {
|
||||
inProgressStatus := &models.TaskStatus{Name: "In Progress"}
|
||||
pendingStatus := &models.TaskStatus{Name: "Pending"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
task *models.Task
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "in progress: status is In Progress",
|
||||
task: &models.Task{Status: inProgressStatus},
|
||||
name: "in progress: InProgress is true",
|
||||
task: &models.Task{InProgress: true},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "not in progress: status is Pending",
|
||||
task: &models.Task{Status: pendingStatus},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "not in progress: no status",
|
||||
task: &models.Task{Status: nil},
|
||||
name: "not in progress: InProgress is false",
|
||||
task: &models.Task{InProgress: false},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -73,22 +73,22 @@ func ScopeNotCompleted(db *gorm.DB) *gorm.DB {
|
||||
)
|
||||
}
|
||||
|
||||
// ScopeInProgress filters to tasks with status "In Progress".
|
||||
// ScopeInProgress filters to tasks marked as in progress.
|
||||
//
|
||||
// Predicate equivalent: IsInProgress(task)
|
||||
//
|
||||
// SQL: Joins task_taskstatus and filters by name = 'In Progress'
|
||||
// SQL: in_progress = true
|
||||
func ScopeInProgress(db *gorm.DB) *gorm.DB {
|
||||
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
||||
Where("task_taskstatus.name = ?", "In Progress")
|
||||
return db.Where("in_progress = ?", true)
|
||||
}
|
||||
|
||||
// ScopeNotInProgress excludes tasks with status "In Progress".
|
||||
// ScopeNotInProgress excludes tasks marked as in progress.
|
||||
//
|
||||
// Predicate equivalent: !IsInProgress(task)
|
||||
//
|
||||
// SQL: in_progress = false
|
||||
func ScopeNotInProgress(db *gorm.DB) *gorm.DB {
|
||||
return db.Joins("LEFT JOIN task_taskstatus ON task_taskstatus.id = task_task.status_id").
|
||||
Where("task_taskstatus.name != ? OR task_taskstatus.name IS NULL", "In Progress")
|
||||
return db.Where("in_progress = ?", false)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -54,7 +54,6 @@ func TestMain(m *testing.M) {
|
||||
err = testDB.AutoMigrate(
|
||||
&models.Task{},
|
||||
&models.TaskCompletion{},
|
||||
&models.TaskStatus{},
|
||||
&models.Residence{},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -77,7 +76,6 @@ func cleanupTestData() {
|
||||
}
|
||||
testDB.Exec("DELETE FROM task_taskcompletion WHERE task_id IN (SELECT id FROM task_task WHERE title LIKE 'test_%')")
|
||||
testDB.Exec("DELETE FROM task_task WHERE title LIKE 'test_%'")
|
||||
testDB.Exec("DELETE FROM task_taskstatus WHERE name LIKE 'test_%'")
|
||||
testDB.Exec("DELETE FROM residence_residence WHERE name LIKE 'test_%'")
|
||||
}
|
||||
|
||||
@@ -102,16 +100,6 @@ func createTestResidence(t *testing.T) uint {
|
||||
return residence.ID
|
||||
}
|
||||
|
||||
// createTestStatus creates a test status and returns it
|
||||
func createTestStatus(t *testing.T, name string) *models.TaskStatus {
|
||||
status := &models.TaskStatus{
|
||||
Name: "test_" + name,
|
||||
}
|
||||
if err := testDB.Create(status).Error; err != nil {
|
||||
t.Fatalf("Failed to create test status: %v", err)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// createTestTask creates a task with the given properties
|
||||
func createTestTask(t *testing.T, residenceID uint, task *models.Task) *models.Task {
|
||||
@@ -587,41 +575,18 @@ func TestScopeInProgressMatchesPredicate(t *testing.T) {
|
||||
}
|
||||
|
||||
residenceID := createTestResidence(t)
|
||||
|
||||
// For InProgress, we need to use the exact status name "In Progress" because
|
||||
// the scope joins on task_taskstatus.name = 'In Progress'
|
||||
// First, try to find existing "In Progress" status, or create one
|
||||
var inProgressStatus models.TaskStatus
|
||||
if err := testDB.Where("name = ?", "In Progress").First(&inProgressStatus).Error; err != nil {
|
||||
// Create it if it doesn't exist
|
||||
inProgressStatus = models.TaskStatus{Name: "In Progress"}
|
||||
testDB.Create(&inProgressStatus)
|
||||
}
|
||||
|
||||
var pendingStatus models.TaskStatus
|
||||
if err := testDB.Where("name = ?", "Pending").First(&pendingStatus).Error; err != nil {
|
||||
pendingStatus = models.TaskStatus{Name: "Pending"}
|
||||
testDB.Create(&pendingStatus)
|
||||
}
|
||||
|
||||
defer cleanupTestData()
|
||||
|
||||
// In progress task
|
||||
createTestTask(t, residenceID, &models.Task{
|
||||
Title: "in_progress",
|
||||
StatusID: &inProgressStatus.ID,
|
||||
Title: "in_progress",
|
||||
InProgress: true,
|
||||
})
|
||||
|
||||
// Not in progress: different status
|
||||
// Not in progress: InProgress is false
|
||||
createTestTask(t, residenceID, &models.Task{
|
||||
Title: "pending",
|
||||
StatusID: &pendingStatus.ID,
|
||||
})
|
||||
|
||||
// Not in progress: no status
|
||||
createTestTask(t, residenceID, &models.Task{
|
||||
Title: "no_status",
|
||||
StatusID: nil,
|
||||
Title: "not_in_progress",
|
||||
InProgress: false,
|
||||
})
|
||||
|
||||
// Query using scope
|
||||
@@ -633,9 +598,9 @@ func TestScopeInProgressMatchesPredicate(t *testing.T) {
|
||||
t.Fatalf("Scope query failed: %v", err)
|
||||
}
|
||||
|
||||
// Query all tasks with status preloaded and filter with predicate
|
||||
// Query all tasks and filter with predicate
|
||||
var allTasks []models.Task
|
||||
testDB.Preload("Status").Where("residence_id = ?", residenceID).Find(&allTasks)
|
||||
testDB.Where("residence_id = ?", residenceID).Find(&allTasks)
|
||||
|
||||
var predicateResults []models.Task
|
||||
for _, task := range allTasks {
|
||||
|
||||
Reference in New Issue
Block a user