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:
Trey t
2026-03-12 00:05:10 -05:00
parent 739b245ee6
commit 6803f6ec18
12 changed files with 958 additions and 21 deletions

View File

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