refactor: extract streak calculation to domain service

Move streak logic from application query to a dedicated domain service,
making it reusable and properly tested for all habit configurations.

- Support streaks for all habit types (boolean, counter, value) combined
  with positive/negative and optional target values
- Handle weekly habits with specific days (streak counts scheduled days)
- Today completed counts toward streak; not yet completed doesn't break it
- Calculate current and longest streak in a single forward pass
- Remove completion_rate from stats (not a useful metric for habits)
- Add comprehensive unit tests covering all 10 type combinations
This commit is contained in:
2026-03-07 22:43:48 +01:00
parent 77cfb709d8
commit 2b059ec334
7 changed files with 450 additions and 104 deletions
+13 -94
View File
@@ -6,18 +6,18 @@ import (
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type HabitStatsDTO struct {
HabitID string `json:"habit_id"`
HabitName string `json:"habit_name"`
TotalCompletions int `json:"total_completions"`
CurrentStreak int `json:"current_streak"`
LongestStreak int `json:"longest_streak"`
CompletionRate float64 `json:"completion_rate"`
CompletionsThisWeek int `json:"completions_this_week"`
CompletionsThisMonth int `json:"completions_this_month"`
HabitID string `json:"habit_id"`
HabitName string `json:"habit_name"`
TotalCompletions int `json:"total_completions"`
CurrentStreak int `json:"current_streak"`
LongestStreak int `json:"longest_streak"`
CompletionsThisWeek int `json:"completions_this_week"`
CompletionsThisMonth int `json:"completions_this_month"`
}
type GetHabitStatsQuery struct {
@@ -60,102 +60,21 @@ func (h *GetHabitStatsHandler) Handle(ctx context.Context, query GetHabitStatsQu
HabitName: habit.Name,
}
if len(entries) == 0 {
if len(entries) == 0 && !habit.IsNegative {
return stats, nil
}
stats.TotalCompletions = len(entries)
stats.CurrentStreak = calculateCurrentStreak(entries)
stats.LongestStreak = calculateLongestStreak(entries)
stats.CompletionRate = calculateCompletionRate(entries, habit.CreatedAt)
stats.CompletionsThisWeek = countCompletionsInPeriod(entries, 7)
stats.CompletionsThisMonth = countCompletionsInPeriod(entries, 30)
streaks := services.CalculateStreaks(entries, habit, time.Now().UTC())
stats.CurrentStreak = streaks.Current
stats.LongestStreak = streaks.Longest
return stats, nil
}
func calculateCurrentStreak(entries []*entities.HabitEntry) int {
if len(entries) == 0 {
return 0
}
dateMap := make(map[string]bool)
for _, entry := range entries {
dateStr := entry.ScheduledDate.Format("2006-01-02")
dateMap[dateStr] = true
}
streak := 0
currentDate := time.Now().UTC()
for {
dateStr := currentDate.Format("2006-01-02")
if !dateMap[dateStr] {
break
}
streak++
currentDate = currentDate.AddDate(0, 0, -1)
}
return streak
}
func calculateLongestStreak(entries []*entities.HabitEntry) int {
if len(entries) == 0 {
return 0
}
dateMap := make(map[string]bool)
var dates []time.Time
for _, entry := range entries {
date := time.Date(entry.ScheduledDate.Year(), entry.ScheduledDate.Month(), entry.ScheduledDate.Day(), 0, 0, 0, 0, time.UTC)
dateStr := date.Format("2006-01-02")
if !dateMap[dateStr] {
dateMap[dateStr] = true
dates = append(dates, date)
}
}
if len(dates) == 0 {
return 0
}
longestStreak := 1
currentStreak := 1
for i := 1; i < len(dates); i++ {
diff := dates[i].Sub(dates[i-1]).Hours() / 24
if diff == 1 {
currentStreak++
if currentStreak > longestStreak {
longestStreak = currentStreak
}
} else {
currentStreak = 1
}
}
return longestStreak
}
func calculateCompletionRate(entries []*entities.HabitEntry, createdAt time.Time) float64 {
if len(entries) == 0 {
return 0
}
daysSinceCreation := int(time.Since(createdAt).Hours() / 24)
if daysSinceCreation == 0 {
daysSinceCreation = 1
}
rate := float64(len(entries)) / float64(daysSinceCreation) * 100
if rate > 100 {
rate = 100
}
return rate
}
func countCompletionsInPeriod(entries []*entities.HabitEntry, days int) int {
cutoff := time.Now().UTC().AddDate(0, 0, -days)
count := 0