Add honeycomb completion heatmap and data migration framework
- Add completion_summary endpoint data to residence detail response - Track completed_from_column on task completions (overdue/due_soon/upcoming) - Add GetCompletionSummary repo method with monthly aggregation - Add one-time data migration framework (data_migrations table + registry) - Add backfill migration to classify historical completions - Add standalone backfill script for manual/dry-run usage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -58,8 +58,9 @@ func (s *ResidenceService) SetSubscriptionService(subService *SubscriptionServic
|
||||
s.subscriptionService = subService
|
||||
}
|
||||
|
||||
// GetResidence gets a residence by ID with access check
|
||||
func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.ResidenceResponse, error) {
|
||||
// GetResidence gets a residence by ID with access check.
|
||||
// The `now` parameter is used for timezone-aware completion summary aggregation.
|
||||
func (s *ResidenceService) GetResidence(residenceID, userID uint, now time.Time) (*responses.ResidenceResponse, error) {
|
||||
// Check access
|
||||
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
||||
if err != nil {
|
||||
@@ -78,6 +79,17 @@ func (s *ResidenceService) GetResidence(residenceID, userID uint) (*responses.Re
|
||||
}
|
||||
|
||||
resp := responses.NewResidenceResponse(residence)
|
||||
|
||||
// Attach completion summary (honeycomb grid data)
|
||||
if s.taskRepo != nil {
|
||||
summary, err := s.taskRepo.GetCompletionSummary(residenceID, now, 10)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Uint("residence_id", residenceID).Msg("Failed to fetch completion summary")
|
||||
} else {
|
||||
resp.CompletionSummary = summary
|
||||
}
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ func TestResidenceService_GetResidence(t *testing.T) {
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
|
||||
resp, err := service.GetResidence(residence.ID, user.ID)
|
||||
resp, err := service.GetResidence(residence.ID, user.ID, time.Now())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, residence.ID, resp.ID)
|
||||
assert.Equal(t, "Test House", resp.Name)
|
||||
@@ -119,7 +119,7 @@ func TestResidenceService_GetResidence_AccessDenied(t *testing.T) {
|
||||
otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, owner.ID, "Test House")
|
||||
|
||||
_, err := service.GetResidence(residence.ID, otherUser.ID)
|
||||
_, err := service.GetResidence(residence.ID, otherUser.ID, time.Now())
|
||||
testutil.AssertAppError(t, err, http.StatusForbidden, "error.residence_access_denied")
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestResidenceService_GetResidence_NotFound(t *testing.T) {
|
||||
|
||||
user := testutil.CreateTestUser(t, db, "user", "user@test.com", "password")
|
||||
|
||||
_, err := service.GetResidence(9999, user.ID)
|
||||
_, err := service.GetResidence(9999, user.ID, time.Now())
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ func TestResidenceService_DeleteResidence(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not be found
|
||||
_, err = service.GetResidence(residence.ID, user.ID)
|
||||
_, err = service.GetResidence(residence.ID, user.ID, time.Now())
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/task/categorization"
|
||||
)
|
||||
|
||||
// Task-related errors (DEPRECATED - kept for reference, use apperrors instead)
|
||||
@@ -551,13 +552,18 @@ func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest
|
||||
completedAt = *req.CompletedAt
|
||||
}
|
||||
|
||||
// Capture the kanban column BEFORE mutating NextDueDate/InProgress,
|
||||
// so we know what state the task was in when the user completed it.
|
||||
completedFromColumn := categorization.DetermineKanbanColumnWithTime(task, 30, now)
|
||||
|
||||
completion := &models.TaskCompletion{
|
||||
TaskID: req.TaskID,
|
||||
CompletedByID: userID,
|
||||
CompletedAt: completedAt,
|
||||
Notes: req.Notes,
|
||||
ActualCost: req.ActualCost,
|
||||
Rating: req.Rating,
|
||||
TaskID: req.TaskID,
|
||||
CompletedByID: userID,
|
||||
CompletedAt: completedAt,
|
||||
Notes: req.Notes,
|
||||
ActualCost: req.ActualCost,
|
||||
Rating: req.Rating,
|
||||
CompletedFromColumn: completedFromColumn,
|
||||
}
|
||||
|
||||
// Determine interval days for NextDueDate calculation before entering the transaction.
|
||||
@@ -680,11 +686,15 @@ func (s *TaskService) QuickComplete(taskID uint, userID uint) error {
|
||||
|
||||
completedAt := time.Now().UTC()
|
||||
|
||||
// Capture kanban column before state mutation
|
||||
completedFromColumn := categorization.DetermineKanbanColumn(task, 30)
|
||||
|
||||
completion := &models.TaskCompletion{
|
||||
TaskID: taskID,
|
||||
CompletedByID: userID,
|
||||
CompletedAt: completedAt,
|
||||
Notes: "Completed from widget",
|
||||
TaskID: taskID,
|
||||
CompletedByID: userID,
|
||||
CompletedAt: completedAt,
|
||||
Notes: "Completed from widget",
|
||||
CompletedFromColumn: completedFromColumn,
|
||||
}
|
||||
|
||||
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user