2b059ec334
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
99 lines
2.2 KiB
Go
99 lines
2.2 KiB
Go
package services
|
|
|
|
import (
|
|
"time"
|
|
|
|
"apocapoc-api/internal/domain/entities"
|
|
"apocapoc-api/internal/shared/utils"
|
|
)
|
|
|
|
type StreakResult struct {
|
|
Current int
|
|
Longest int
|
|
}
|
|
|
|
func CalculateStreaks(entries []*entities.HabitEntry, habit *entities.Habit, now time.Time) StreakResult {
|
|
entryMap := buildEntryMap(entries)
|
|
scheduled := allScheduledDates(habit, now)
|
|
|
|
if len(scheduled) == 0 {
|
|
return StreakResult{}
|
|
}
|
|
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
longest := 0
|
|
current := 0
|
|
|
|
for _, d := range scheduled {
|
|
if IsDaySuccessful(d.Format("2006-01-02"), entryMap, habit) {
|
|
current++
|
|
if current > longest {
|
|
longest = current
|
|
}
|
|
} else if d.Equal(today) && !habit.IsNegative {
|
|
continue
|
|
} else {
|
|
current = 0
|
|
}
|
|
}
|
|
|
|
return StreakResult{Current: current, Longest: longest}
|
|
}
|
|
|
|
func buildEntryMap(entries []*entities.HabitEntry) map[string]*entities.HabitEntry {
|
|
m := make(map[string]*entities.HabitEntry)
|
|
for _, e := range entries {
|
|
m[e.ScheduledDate.Format("2006-01-02")] = e
|
|
}
|
|
return m
|
|
}
|
|
|
|
func allScheduledDates(habit *entities.Habit, now time.Time) []time.Time {
|
|
start := time.Date(habit.CreatedAt.Year(), habit.CreatedAt.Month(), habit.CreatedAt.Day(), 0, 0, 0, 0, time.UTC)
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
freq := string(habit.Frequency)
|
|
|
|
var dates []time.Time
|
|
for d := start; !d.After(today); d = d.AddDate(0, 0, 1) {
|
|
if utils.ShouldAppearToday(freq, habit.SpecificDays, habit.SpecificDates, d) {
|
|
dates = append(dates, d)
|
|
}
|
|
}
|
|
return dates
|
|
}
|
|
|
|
func IsDaySuccessful(dateStr string, entryMap map[string]*entities.HabitEntry, habit *entities.Habit) bool {
|
|
entry, hasEntry := entryMap[dateStr]
|
|
habitType := string(habit.Type)
|
|
|
|
if !habit.IsNegative {
|
|
if !hasEntry {
|
|
return false
|
|
}
|
|
if habit.TargetValue != nil && entry.Value != nil {
|
|
return *entry.Value >= *habit.TargetValue
|
|
}
|
|
return true
|
|
}
|
|
|
|
if habitType == "VALUE" && habit.TargetValue != nil {
|
|
if !hasEntry {
|
|
return false
|
|
}
|
|
if entry.Value != nil {
|
|
return *entry.Value <= *habit.TargetValue
|
|
}
|
|
return false
|
|
}
|
|
|
|
if !hasEntry {
|
|
return true
|
|
}
|
|
|
|
if habit.TargetValue != nil && entry.Value != nil {
|
|
return *entry.Value <= *habit.TargetValue
|
|
}
|
|
|
|
return false
|
|
}
|