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:
174
internal/repositories/completion_summary_test.go
Normal file
174
internal/repositories/completion_summary_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/testutil"
|
||||
)
|
||||
|
||||
func TestGetCompletionSummary_EmptyResidence(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewTaskRepository(db)
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
summary, err := repo.GetCompletionSummary(residence.ID, now, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, summary.TotalAllTime)
|
||||
assert.Equal(t, 0, summary.TotalLast12Months)
|
||||
assert.Len(t, summary.Months, 12)
|
||||
// First month should be March 2025
|
||||
assert.Equal(t, "2025-03", summary.Months[0].Month)
|
||||
// Last month should be February 2026
|
||||
assert.Equal(t, "2026-02", summary.Months[11].Month)
|
||||
}
|
||||
|
||||
func TestGetCompletionSummary_CountsByMonthAndColumn(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewTaskRepository(db)
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Test House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Fix roof")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
completions := []models.TaskCompletion{
|
||||
{TaskID: task.ID, CompletedByID: user.ID, CompletedAt: time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC), CompletedFromColumn: "overdue_tasks"},
|
||||
{TaskID: task.ID, CompletedByID: user.ID, CompletedAt: time.Date(2026, 1, 20, 12, 0, 0, 0, time.UTC), CompletedFromColumn: "completed_tasks"},
|
||||
{TaskID: task.ID, CompletedByID: user.ID, CompletedAt: time.Date(2026, 2, 5, 12, 0, 0, 0, time.UTC), CompletedFromColumn: "due_soon_tasks"},
|
||||
{TaskID: task.ID, CompletedByID: user.ID, CompletedAt: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), CompletedFromColumn: "upcoming_tasks"},
|
||||
}
|
||||
for i := range completions {
|
||||
require.NoError(t, db.Create(&completions[i]).Error)
|
||||
}
|
||||
|
||||
summary, err := repo.GetCompletionSummary(residence.ID, now, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 4, summary.TotalAllTime)
|
||||
assert.Equal(t, 4, summary.TotalLast12Months)
|
||||
|
||||
for _, m := range summary.Months {
|
||||
switch m.Month {
|
||||
case "2026-01":
|
||||
assert.Equal(t, 2, m.Total, "January should have 2 completions")
|
||||
assert.Len(t, m.Completions, 2, "January should have 2 column entries")
|
||||
case "2026-02":
|
||||
assert.Equal(t, 1, m.Total, "February should have 1 completion")
|
||||
case "2025-06":
|
||||
assert.Equal(t, 1, m.Total, "June 2025 should have 1 completion")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCompletionSummary_Overflow(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewTaskRepository(db)
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Busy House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Lots of tasks")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for i := 0; i < 15; i++ {
|
||||
c := models.TaskCompletion{
|
||||
TaskID: task.ID,
|
||||
CompletedByID: user.ID,
|
||||
CompletedAt: time.Date(2026, 2, 1+i, 12, 0, 0, 0, time.UTC),
|
||||
CompletedFromColumn: "completed_tasks",
|
||||
}
|
||||
require.NoError(t, db.Create(&c).Error)
|
||||
}
|
||||
|
||||
summary, err := repo.GetCompletionSummary(residence.ID, now, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, m := range summary.Months {
|
||||
if m.Month == "2026-02" {
|
||||
assert.Equal(t, 15, m.Total)
|
||||
assert.Equal(t, 5, m.Overflow)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("February 2026 not found")
|
||||
}
|
||||
|
||||
func TestGetCompletionSummary_OldCompletionsExcludedFromMonths(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewTaskRepository(db)
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Old House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Old task")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
old := models.TaskCompletion{
|
||||
TaskID: task.ID, CompletedByID: user.ID,
|
||||
CompletedAt: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), CompletedFromColumn: "completed_tasks",
|
||||
}
|
||||
require.NoError(t, db.Create(&old).Error)
|
||||
|
||||
recent := models.TaskCompletion{
|
||||
TaskID: task.ID, CompletedByID: user.ID,
|
||||
CompletedAt: time.Date(2026, 2, 1, 12, 0, 0, 0, time.UTC), CompletedFromColumn: "completed_tasks",
|
||||
}
|
||||
require.NoError(t, db.Create(&recent).Error)
|
||||
|
||||
summary, err := repo.GetCompletionSummary(residence.ID, now, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, summary.TotalAllTime)
|
||||
assert.Equal(t, 1, summary.TotalLast12Months)
|
||||
}
|
||||
|
||||
func TestGetCompletionSummary_CompletionColors(t *testing.T) {
|
||||
db := testutil.SetupTestDB(t)
|
||||
repo := NewTaskRepository(db)
|
||||
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
|
||||
residence := testutil.CreateTestResidence(t, db, user.ID, "Color House")
|
||||
task := testutil.CreateTestTask(t, db, residence.ID, user.ID, "Color task")
|
||||
now := time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
c := models.TaskCompletion{
|
||||
TaskID: task.ID, CompletedByID: user.ID,
|
||||
CompletedAt: time.Date(2026, 1, 5, 12, 0, 0, 0, time.UTC), CompletedFromColumn: "overdue_tasks",
|
||||
}
|
||||
require.NoError(t, db.Create(&c).Error)
|
||||
|
||||
summary, err := repo.GetCompletionSummary(residence.ID, now, 10)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, m := range summary.Months {
|
||||
if m.Month == "2026-01" {
|
||||
require.Len(t, m.Completions, 1)
|
||||
assert.Equal(t, "overdue_tasks", m.Completions[0].Column)
|
||||
assert.Equal(t, "#FF3B30", m.Completions[0].Color)
|
||||
assert.Equal(t, 1, m.Completions[0].Count)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("January 2026 not found")
|
||||
}
|
||||
|
||||
func TestKanbanColumnColor(t *testing.T) {
|
||||
tests := []struct {
|
||||
column string
|
||||
expected string
|
||||
}{
|
||||
{"overdue_tasks", "#FF3B30"},
|
||||
{"in_progress_tasks", "#5856D6"},
|
||||
{"due_soon_tasks", "#FF9500"},
|
||||
{"upcoming_tasks", "#007AFF"},
|
||||
{"completed_tasks", "#34C759"},
|
||||
{"cancelled_tasks", "#8E8E93"},
|
||||
{"unknown_column", "#34C759"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.column, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, KanbanColumnColor(tt.column))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/dto/responses"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/task"
|
||||
"github.com/treytartt/honeydue-api/internal/task/categorization"
|
||||
@@ -789,3 +790,125 @@ func (r *TaskRepository) GetOverdueCountByResidence(residenceIDs []uint, now tim
|
||||
|
||||
return countMap, nil
|
||||
}
|
||||
|
||||
// kanbanColumnColors maps kanban column names to their hex colors.
|
||||
var kanbanColumnColors = map[string]string{
|
||||
"overdue_tasks": "#FF3B30",
|
||||
"in_progress_tasks": "#5856D6",
|
||||
"due_soon_tasks": "#FF9500",
|
||||
"upcoming_tasks": "#007AFF",
|
||||
"completed_tasks": "#34C759",
|
||||
"cancelled_tasks": "#8E8E93",
|
||||
}
|
||||
|
||||
// KanbanColumnColor returns the hex color for a kanban column name.
|
||||
func KanbanColumnColor(column string) string {
|
||||
if color, ok := kanbanColumnColors[column]; ok {
|
||||
return color
|
||||
}
|
||||
return "#34C759" // default to green
|
||||
}
|
||||
|
||||
// completionAggRow is an internal type for scanning aggregated completion data.
|
||||
type completionAggRow struct {
|
||||
ResidenceID uint
|
||||
CompletedFromColumn string
|
||||
CompletedMonth string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// GetCompletionSummary returns completion summary data for a single residence.
|
||||
// Returns total all-time count and monthly breakdowns (by column) for the last 12 months.
|
||||
func (r *TaskRepository) GetCompletionSummary(residenceID uint, now time.Time, maxPerMonth int) (*responses.CompletionSummary, error) {
|
||||
// 1. Total all-time completions for this residence
|
||||
var totalAllTime int64
|
||||
err := r.db.Model(&models.TaskCompletion{}).
|
||||
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
||||
Where("task_task.residence_id = ?", residenceID).
|
||||
Count(&totalAllTime).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Monthly breakdown for last 12 months
|
||||
startDate := time.Date(now.Year()-1, now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
// Use dialect-appropriate date formatting (PostgreSQL vs SQLite)
|
||||
dateExpr := "TO_CHAR(task_taskcompletion.completed_at, 'YYYY-MM')"
|
||||
if r.db.Dialector.Name() == "sqlite" {
|
||||
dateExpr = "strftime('%Y-%m', task_taskcompletion.completed_at)"
|
||||
}
|
||||
|
||||
var rows []completionAggRow
|
||||
err = r.db.Model(&models.TaskCompletion{}).
|
||||
Select(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s as completed_month, COUNT(*) as count", dateExpr)).
|
||||
Joins("JOIN task_task ON task_task.id = task_taskcompletion.task_id").
|
||||
Where("task_task.residence_id = ? AND task_taskcompletion.completed_at >= ?", residenceID, startDate).
|
||||
Group(fmt.Sprintf("task_task.residence_id, task_taskcompletion.completed_from_column, %s", dateExpr)).
|
||||
Order("completed_month ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build month map
|
||||
type monthData struct {
|
||||
columns map[string]int
|
||||
total int
|
||||
}
|
||||
monthMap := make(map[string]*monthData)
|
||||
|
||||
// Initialize all 12 months
|
||||
for i := 0; i < 12; i++ {
|
||||
m := startDate.AddDate(0, i, 0)
|
||||
key := m.Format("2006-01")
|
||||
monthMap[key] = &monthData{columns: make(map[string]int)}
|
||||
}
|
||||
|
||||
// Populate from query results
|
||||
totalLast12 := 0
|
||||
for _, row := range rows {
|
||||
md, ok := monthMap[row.CompletedMonth]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
md.columns[row.CompletedFromColumn] = int(row.Count)
|
||||
md.total += int(row.Count)
|
||||
totalLast12 += int(row.Count)
|
||||
}
|
||||
|
||||
// Convert to response DTOs
|
||||
months := make([]responses.MonthlyCompletionSummary, 0, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
m := startDate.AddDate(0, i, 0)
|
||||
key := m.Format("2006-01")
|
||||
md := monthMap[key]
|
||||
|
||||
completions := make([]responses.ColumnCompletionCount, 0)
|
||||
for col, count := range md.columns {
|
||||
completions = append(completions, responses.ColumnCompletionCount{
|
||||
Column: col,
|
||||
Color: KanbanColumnColor(col),
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
|
||||
overflow := 0
|
||||
if md.total > maxPerMonth {
|
||||
overflow = md.total - maxPerMonth
|
||||
}
|
||||
|
||||
months = append(months, responses.MonthlyCompletionSummary{
|
||||
Month: key,
|
||||
Completions: completions,
|
||||
Total: md.total,
|
||||
Overflow: overflow,
|
||||
})
|
||||
}
|
||||
|
||||
return &responses.CompletionSummary{
|
||||
TotalAllTime: int(totalAllTime),
|
||||
TotalLast12Months: totalLast12,
|
||||
Months: months,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user