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

@@ -168,7 +168,6 @@ const (
LookupKeyPrefix = "lookup:"
LookupCategoriesKey = LookupKeyPrefix + "categories"
LookupPrioritiesKey = LookupKeyPrefix + "priorities"
LookupStatusesKey = LookupKeyPrefix + "statuses"
LookupFrequenciesKey = LookupKeyPrefix + "frequencies"
LookupResidenceTypesKey = LookupKeyPrefix + "residence_types"
LookupSpecialtiesKey = LookupKeyPrefix + "specialties"
@@ -196,7 +195,6 @@ func (c *CacheService) InvalidateAllLookups(ctx context.Context) error {
keys := []string{
LookupCategoriesKey,
LookupPrioritiesKey,
LookupStatusesKey,
LookupFrequenciesKey,
LookupResidenceTypesKey,
LookupSpecialtiesKey,
@@ -239,21 +237,6 @@ func (c *CacheService) InvalidatePriorities(ctx context.Context) error {
return c.Delete(ctx, LookupPrioritiesKey, StaticDataKey)
}
// CacheStatuses caches task statuses
func (c *CacheService) CacheStatuses(ctx context.Context, data interface{}) error {
return c.CacheLookupData(ctx, LookupStatusesKey, data)
}
// GetCachedStatuses retrieves cached task statuses
func (c *CacheService) GetCachedStatuses(ctx context.Context, dest interface{}) error {
return c.GetCachedLookupData(ctx, LookupStatusesKey, dest)
}
// InvalidateStatuses removes cached task statuses
func (c *CacheService) InvalidateStatuses(ctx context.Context) error {
return c.Delete(ctx, LookupStatusesKey, StaticDataKey)
}
// CacheFrequencies caches task frequencies
func (c *CacheService) CacheFrequencies(ctx context.Context, data interface{}) error {
return c.CacheLookupData(ctx, LookupFrequenciesKey, data)

View File

@@ -616,8 +616,8 @@ func (s *ResidenceService) GenerateTasksReport(residenceID, userID uint) (*Tasks
if task.Priority != nil {
taskData.Priority = task.Priority.Name
}
if task.Status != nil {
taskData.Status = task.Status.Name
if task.InProgress {
taskData.Status = "In Progress"
}
// Use effective date for report (NextDueDate ?? DueDate)
effectiveDate := predicates.EffectiveDate(&task)

View File

@@ -42,12 +42,12 @@ func TestResidenceService_CreateResidence(t *testing.T) {
resp, err := service.CreateResidence(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Test House", resp.Name)
assert.Equal(t, "123 Main St", resp.StreetAddress)
assert.Equal(t, "Austin", resp.City)
assert.Equal(t, "TX", resp.StateProvince)
assert.Equal(t, "USA", resp.Country) // Default country
assert.True(t, resp.IsPrimary) // Default is_primary
assert.Equal(t, "Test House", resp.Data.Name)
assert.Equal(t, "123 Main St", resp.Data.StreetAddress)
assert.Equal(t, "Austin", resp.Data.City)
assert.Equal(t, "TX", resp.Data.StateProvince)
assert.Equal(t, "USA", resp.Data.Country) // Default country
assert.True(t, resp.Data.IsPrimary) // Default is_primary
}
func TestResidenceService_CreateResidence_WithOptionalFields(t *testing.T) {
@@ -79,12 +79,12 @@ func TestResidenceService_CreateResidence_WithOptionalFields(t *testing.T) {
resp, err := service.CreateResidence(req, user.ID)
require.NoError(t, err)
assert.Equal(t, "Canada", resp.Country)
assert.Equal(t, 3, *resp.Bedrooms)
assert.True(t, resp.Bathrooms.Equal(decimal.NewFromFloat(2.5)))
assert.Equal(t, 2000, *resp.SquareFootage)
assert.Equal(t, "Canada", resp.Data.Country)
assert.Equal(t, 3, *resp.Data.Bedrooms)
assert.True(t, resp.Data.Bathrooms.Equal(decimal.NewFromFloat(2.5)))
assert.Equal(t, 2000, *resp.Data.SquareFootage)
// First residence defaults to primary regardless of request
assert.True(t, resp.IsPrimary)
assert.True(t, resp.Data.IsPrimary)
}
func TestResidenceService_GetResidence(t *testing.T) {
@@ -166,8 +166,8 @@ func TestResidenceService_UpdateResidence(t *testing.T) {
resp, err := service.UpdateResidence(residence.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated Name", resp.Name)
assert.Equal(t, "Dallas", resp.City)
assert.Equal(t, "Updated Name", resp.Data.Name)
assert.Equal(t, "Dallas", resp.Data.City)
}
func TestResidenceService_UpdateResidence_NotOwner(t *testing.T) {
@@ -201,7 +201,7 @@ func TestResidenceService_DeleteResidence(t *testing.T) {
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
err := service.DeleteResidence(residence.ID, user.ID)
_, err := service.DeleteResidence(residence.ID, user.ID)
require.NoError(t, err)
// Should not be found
@@ -221,7 +221,7 @@ func TestResidenceService_DeleteResidence_NotOwner(t *testing.T) {
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
residenceRepo.AddUser(residence.ID, sharedUser.ID)
err := service.DeleteResidence(residence.ID, sharedUser.ID)
_, err := service.DeleteResidence(residence.ID, sharedUser.ID)
assert.ErrorIs(t, err, ErrNotResidenceOwner)
}

View File

@@ -33,7 +33,7 @@ KANBAN COLUMNS (in priority order):
----------------------------------
1. CANCELLED: Task.IsCancelled = true
2. COMPLETED: NextDueDate = nil AND has completions (one-time task done)
3. IN_PROGRESS: Status.Name = "In Progress"
3. IN_PROGRESS: InProgress = true
4. OVERDUE: NextDueDate < now
5. DUE_SOON: NextDueDate < now + daysThreshold (default 30)
6. UPCOMING: Everything else (NextDueDate >= threshold or no due date)
@@ -72,6 +72,14 @@ func daysAgo(n int) time.Time {
return time.Now().UTC().AddDate(0, 0, -n)
}
// isTaskCompleted checks if a task is permanently completed (one-time task done).
// A task is completed when it has completions AND NextDueDate is nil.
func isTaskCompleted(task *models.Task) bool {
if len(task.Completions) == 0 {
return false
}
return task.NextDueDate == nil
}
// ============================================================================
// isTaskCompleted FUNCTION TESTS
@@ -157,7 +165,7 @@ func TestGetButtonTypesForTask_CompletedOneTimeTask(t *testing.T) {
func TestGetButtonTypesForTask_InProgressTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(10)),
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
}
buttons := GetButtonTypesForTask(task, 30)
@@ -237,7 +245,7 @@ func TestGetIOSCategoryForTask_CompletedTask(t *testing.T) {
func TestGetIOSCategoryForTask_InProgressTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysFromNow(10)),
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
}
category := GetIOSCategoryForTask(task)
@@ -285,7 +293,7 @@ func TestDetermineKanbanColumn_CompletedOneTimeTask(t *testing.T) {
func TestDetermineKanbanColumn_InProgressTask(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysAgo(5)), // Even overdue
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
}
column := responses.DetermineKanbanColumn(task, 30)
@@ -902,7 +910,7 @@ func TestEdgeCase_CancelledAndOverdue(t *testing.T) {
func TestEdgeCase_InProgressAndOverdue(t *testing.T) {
task := &models.Task{
NextDueDate: ptr(daysAgo(5)),
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
}
column := responses.DetermineKanbanColumn(task, 30)
@@ -1011,7 +1019,7 @@ func TestButtonTypes_ConsistencyWithKanbanColumn(t *testing.T) {
name: "In Progress task",
task: &models.Task{
NextDueDate: ptr(daysFromNow(10)),
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
},
expectedColumn: "in_progress_tasks",
expectedButtons: []string{"edit", "complete", "cancel"},
@@ -1062,7 +1070,7 @@ func TestPriorityOrder_CancelledBeatsEverything(t *testing.T) {
task := &models.Task{
IsCancelled: true,
NextDueDate: ptr(daysAgo(10)),
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
}
@@ -1074,7 +1082,7 @@ func TestPriorityOrder_CompletedBeatsInProgress(t *testing.T) {
// One-time task with In Progress status but completed
task := &models.Task{
NextDueDate: nil,
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
Completions: []models.TaskCompletion{{CompletedAt: daysAgo(1)}},
}
@@ -1086,7 +1094,7 @@ func TestPriorityOrder_InProgressBeatsDateBased(t *testing.T) {
// Overdue task that's in progress
task := &models.Task{
NextDueDate: ptr(daysAgo(10)),
Status: &models.TaskStatus{Name: "In Progress"},
InProgress: true,
}
column := responses.DetermineKanbanColumn(task, 30)

View File

@@ -173,8 +173,8 @@ func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (
Description: req.Description,
CategoryID: req.CategoryID,
PriorityID: req.PriorityID,
StatusID: req.StatusID,
FrequencyID: req.FrequencyID,
InProgress: req.InProgress,
AssignedToID: req.AssignedToID,
DueDate: dueDate,
NextDueDate: dueDate, // Initialize next_due_date to due_date
@@ -230,12 +230,12 @@ func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRe
if req.PriorityID != nil {
task.PriorityID = req.PriorityID
}
if req.StatusID != nil {
task.StatusID = req.StatusID
}
if req.FrequencyID != nil {
task.FrequencyID = req.FrequencyID
}
if req.InProgress != nil {
task.InProgress = *req.InProgress
}
if req.AssignedToID != nil {
task.AssignedToID = req.AssignedToID
}
@@ -324,13 +324,7 @@ func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskWithSu
return nil, ErrTaskAccessDenied
}
// Find "In Progress" status
status, err := s.taskRepo.FindStatusByName("In Progress")
if err != nil {
return nil, err
}
if err := s.taskRepo.MarkInProgress(taskID, status.ID); err != nil {
if err := s.taskRepo.MarkInProgress(taskID); err != nil {
return nil, err
}
@@ -534,24 +528,22 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
return nil, err
}
// Update next_due_date and status based on frequency
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil and status to "Completed"
// Update next_due_date and in_progress based on frequency
// - If frequency is "Once" (days = nil or 0), set next_due_date to nil (marks as completed)
// - If frequency is recurring, calculate next_due_date = completion_date + frequency_days
// and reset status to "Pending" so task shows in correct kanban column
// and reset in_progress to false so task shows in correct kanban column
if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 {
// One-time task - clear next_due_date and set status to "Completed" (ID=3)
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
task.NextDueDate = nil
completedStatusID := uint(3)
task.StatusID = &completedStatusID
task.InProgress = false
} else {
// Recurring task - calculate next due date from completion date + frequency
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
task.NextDueDate = &nextDue
// Reset status to "Pending" (ID=1) so task appears in upcoming/due_soon
// Reset in_progress to false so task appears in upcoming/due_soon
// instead of staying in "In Progress" column
pendingStatusID := uint(1)
task.StatusID = &pendingStatusID
task.InProgress = false
}
if err := s.taskRepo.Update(task); err != nil {
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after completion")
@@ -633,20 +625,18 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
return err
}
// Update next_due_date and status based on frequency
// Update next_due_date and in_progress based on frequency
if task.Frequency == nil || task.Frequency.Days == nil || *task.Frequency.Days == 0 {
// One-time task - clear next_due_date and set status to "Completed" (ID=3)
// One-time task - clear next_due_date (completion is determined by NextDueDate == nil + has completions)
task.NextDueDate = nil
completedStatusID := uint(3)
task.StatusID = &completedStatusID
task.InProgress = false
} else {
// Recurring task - calculate next due date from completion date + frequency
nextDue := completedAt.AddDate(0, 0, *task.Frequency.Days)
task.NextDueDate = &nextDue
// Reset status to "Pending" (ID=1)
pendingStatusID := uint(1)
task.StatusID = &pendingStatusID
// Reset in_progress to false
task.InProgress = false
}
if err := s.taskRepo.Update(task); err != nil {
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to update task after quick completion")
@@ -858,20 +848,6 @@ func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error)
return result, nil
}
// GetStatuses returns all task statuses
func (s *TaskService) GetStatuses() ([]responses.TaskStatusResponse, error) {
statuses, err := s.taskRepo.GetAllStatuses()
if err != nil {
return nil, err
}
result := make([]responses.TaskStatusResponse, len(statuses))
for i, st := range statuses {
result[i] = *responses.NewTaskStatusResponse(&st)
}
return result, nil
}
// GetFrequencies returns all task frequencies
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
frequencies, err := s.taskRepo.GetAllFrequencies()

View File

@@ -41,9 +41,9 @@ func TestTaskService_CreateTask(t *testing.T) {
resp, err := service.CreateTask(req, user.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Equal(t, "Fix leaky faucet", resp.Title)
assert.Equal(t, "Kitchen faucet is dripping", resp.Description)
assert.NotZero(t, resp.Data.ID)
assert.Equal(t, "Fix leaky faucet", resp.Data.Title)
assert.Equal(t, "Kitchen faucet is dripping", resp.Data.Description)
}
func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
@@ -76,10 +76,10 @@ func TestTaskService_CreateTask_WithOptionalFields(t *testing.T) {
resp, err := service.CreateTask(req, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp.Category)
assert.NotNil(t, resp.Priority)
assert.NotNil(t, resp.DueDate)
assert.NotNil(t, resp.EstimatedCost)
assert.NotNil(t, resp.Data.Category)
assert.NotNil(t, resp.Data.Priority)
assert.NotNil(t, resp.Data.DueDate)
assert.NotNil(t, resp.Data.EstimatedCost)
}
func TestTaskService_CreateTask_AccessDenied(t *testing.T) {
@@ -180,8 +180,8 @@ func TestTaskService_UpdateTask(t *testing.T) {
resp, err := service.UpdateTask(task.ID, user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Updated Title", resp.Title)
assert.Equal(t, "Updated description", resp.Description)
assert.Equal(t, "Updated Title", resp.Data.Title)
assert.Equal(t, "Updated description", resp.Data.Description)
}
func TestTaskService_DeleteTask(t *testing.T) {
@@ -195,7 +195,7 @@ func TestTaskService_DeleteTask(t *testing.T) {
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Test Task")
err := service.DeleteTask(task.ID, user.ID)
_, err := service.DeleteTask(task.ID, user.ID)
require.NoError(t, err)
_, err = service.GetTask(task.ID, user.ID)
@@ -215,7 +215,7 @@ func TestTaskService_CancelTask(t *testing.T) {
resp, err := service.CancelTask(task.ID, user.ID)
require.NoError(t, err)
assert.True(t, resp.IsCancelled)
assert.True(t, resp.Data.IsCancelled)
}
func TestTaskService_CancelTask_AlreadyCancelled(t *testing.T) {
@@ -248,7 +248,7 @@ func TestTaskService_UncancelTask(t *testing.T) {
service.CancelTask(task.ID, user.ID)
resp, err := service.UncancelTask(task.ID, user.ID)
require.NoError(t, err)
assert.False(t, resp.IsCancelled)
assert.False(t, resp.Data.IsCancelled)
}
func TestTaskService_ArchiveTask(t *testing.T) {
@@ -264,7 +264,7 @@ func TestTaskService_ArchiveTask(t *testing.T) {
resp, err := service.ArchiveTask(task.ID, user.ID)
require.NoError(t, err)
assert.True(t, resp.IsArchived)
assert.True(t, resp.Data.IsArchived)
}
func TestTaskService_UnarchiveTask(t *testing.T) {
@@ -281,7 +281,7 @@ func TestTaskService_UnarchiveTask(t *testing.T) {
service.ArchiveTask(task.ID, user.ID)
resp, err := service.UnarchiveTask(task.ID, user.ID)
require.NoError(t, err)
assert.False(t, resp.IsArchived)
assert.False(t, resp.Data.IsArchived)
}
func TestTaskService_MarkInProgress(t *testing.T) {
@@ -297,8 +297,7 @@ func TestTaskService_MarkInProgress(t *testing.T) {
resp, err := service.MarkInProgress(task.ID, user.ID)
require.NoError(t, err)
assert.NotNil(t, resp.Status)
assert.Equal(t, "In Progress", resp.Status.Name)
assert.True(t, resp.Data.InProgress)
}
func TestTaskService_CreateCompletion(t *testing.T) {
@@ -319,12 +318,12 @@ func TestTaskService_CreateCompletion(t *testing.T) {
resp, err := service.CreateCompletion(req, user.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.Equal(t, task.ID, resp.TaskID)
assert.Equal(t, "Completed successfully", resp.Notes)
assert.NotZero(t, resp.Data.ID)
assert.Equal(t, task.ID, resp.Data.TaskID)
assert.Equal(t, "Completed successfully", resp.Data.Notes)
}
func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *testing.T) {
func TestTaskService_CreateCompletion_RecurringTask_ResetsInProgress(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
@@ -334,20 +333,16 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *tes
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
// Get the "In Progress" status (ID=2) and a recurring frequency
var inProgressStatus models.TaskStatus
db.Where("name = ?", "In Progress").First(&inProgressStatus)
var monthlyFrequency models.TaskFrequency
db.Where("name = ?", "Monthly").First(&monthlyFrequency)
// Create a recurring task with "In Progress" status
// Create a recurring task that is in progress
dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days
task := &models.Task{
ResidenceID: residence.ID,
CreatedByID: user.ID,
Title: "Recurring Task",
StatusID: &inProgressStatus.ID,
InProgress: true,
FrequencyID: &monthlyFrequency.ID,
DueDate: &dueDate,
NextDueDate: &dueDate,
@@ -365,24 +360,21 @@ func TestTaskService_CreateCompletion_RecurringTask_ResetsStatusToPending(t *tes
resp, err := service.CreateCompletion(req, user.ID)
require.NoError(t, err)
assert.NotZero(t, resp.ID)
assert.NotZero(t, resp.Data.ID)
// Verify the task in the response has status reset to "Pending" (ID=1)
require.NotNil(t, resp.Task, "Response should include the updated task")
require.NotNil(t, resp.Task.StatusID, "Task should have a status ID")
assert.Equal(t, uint(1), *resp.Task.StatusID, "Recurring task status should be reset to Pending (ID=1) after completion")
// Verify the task in the response has InProgress reset to false
require.NotNil(t, resp.Data.Task, "Response should include the updated task")
assert.False(t, resp.Data.Task.InProgress, "Recurring task InProgress should be reset to false after completion")
// Verify NextDueDate was updated (should be ~30 days from now for monthly)
require.NotNil(t, resp.Task.NextDueDate, "Recurring task should have NextDueDate set")
require.NotNil(t, resp.Data.Task.NextDueDate, "Recurring task should have NextDueDate set")
expectedNextDue := time.Now().AddDate(0, 0, 30) // Monthly = 30 days
assert.WithinDuration(t, expectedNextDue, *resp.Task.NextDueDate, 24*time.Hour, "NextDueDate should be approximately 30 days from now")
assert.WithinDuration(t, expectedNextDue, *resp.Data.Task.NextDueDate, 24*time.Hour, "NextDueDate should be approximately 30 days from now")
// Also verify by reloading from database directly
var reloadedTask models.Task
db.Preload("Status").First(&reloadedTask, task.ID)
require.NotNil(t, reloadedTask.StatusID)
assert.Equal(t, uint(1), *reloadedTask.StatusID, "Database should show Pending status")
assert.Equal(t, "Pending", reloadedTask.Status.Name)
db.First(&reloadedTask, task.ID)
assert.False(t, reloadedTask.InProgress, "Database should show InProgress=false")
}
func TestTaskService_GetCompletion(t *testing.T) {
@@ -428,7 +420,7 @@ func TestTaskService_DeleteCompletion(t *testing.T) {
}
db.Create(completion)
err := service.DeleteCompletion(completion.ID, user.ID)
_, err := service.DeleteCompletion(completion.ID, user.ID)
require.NoError(t, err)
_, err = service.GetCompletion(completion.ID, user.ID)
@@ -470,18 +462,6 @@ func TestTaskService_GetPriorities(t *testing.T) {
}
}
func TestTaskService_GetStatuses(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
service := NewTaskService(taskRepo, residenceRepo)
statuses, err := service.GetStatuses()
require.NoError(t, err)
assert.Greater(t, len(statuses), 0)
}
func TestTaskService_GetFrequencies(t *testing.T) {
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)