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:
Trey t
2025-12-08 20:48:16 -06:00
parent cb250f108b
commit c5b0225422
43 changed files with 353 additions and 753 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}
// =============================================================================

View File

@@ -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,
},
}

View File

@@ -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)
}
// =============================================================================

View File

@@ -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 {