diff --git a/docs/docs.go b/docs/docs.go index 17c74bb..2cdb84d 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1978,9 +1978,6 @@ const docTemplate = `{ "queries.HabitStatsDTO": { "type": "object", "properties": { - "completion_rate": { - "type": "number" - }, "completions_this_month": { "type": "integer" }, diff --git a/docs/swagger.json b/docs/swagger.json index 009d6dd..5cbd04c 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1970,9 +1970,6 @@ "queries.HabitStatsDTO": { "type": "object", "properties": { - "completion_rate": { - "type": "number" - }, "completions_this_month": { "type": "integer" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index df46570..44f162f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -384,8 +384,6 @@ definitions: type: object queries.HabitStatsDTO: properties: - completion_rate: - type: number completions_this_month: type: integer completions_this_week: diff --git a/internal/application/queries/get_habit_stats.go b/internal/application/queries/get_habit_stats.go index 514782f..c59f7b0 100644 --- a/internal/application/queries/get_habit_stats.go +++ b/internal/application/queries/get_habit_stats.go @@ -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 diff --git a/internal/domain/services/streak.go b/internal/domain/services/streak.go new file mode 100644 index 0000000..cd5ca2d --- /dev/null +++ b/internal/domain/services/streak.go @@ -0,0 +1,98 @@ +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 +} diff --git a/internal/domain/services/streak_test.go b/internal/domain/services/streak_test.go new file mode 100644 index 0000000..bde0904 --- /dev/null +++ b/internal/domain/services/streak_test.go @@ -0,0 +1,337 @@ +package services + +import ( + "testing" + "time" + + "apocapoc-api/internal/domain/entities" + "apocapoc-api/internal/domain/value_objects" +) + +func makeEntry(d time.Time, value *float64) *entities.HabitEntry { + return &entities.HabitEntry{ScheduledDate: d, Value: value} +} + +func floatPtr(v float64) *float64 { return &v } + +func dt(year, month, day int) time.Time { + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) +} + +func habit(t value_objects.HabitType, negative bool, target *float64) *entities.Habit { + return &entities.Habit{ + Type: t, + Frequency: value_objects.FrequencyDaily, + IsNegative: negative, + TargetValue: target, + CreatedAt: dt(2026, 1, 1), + } +} + +// --- IsDaySuccessful --- + +func TestIsDaySuccessful_BooleanPositive(t *testing.T) { + h := habit(value_objects.HabitTypeBoolean, false, nil) + em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)} + + if !IsDaySuccessful("2026-03-01", em, h) { + t.Error("entry should be success") + } + if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) { + t.Error("no entry should be failure") + } +} + +func TestIsDaySuccessful_BooleanNegative(t *testing.T) { + h := habit(value_objects.HabitTypeBoolean, true, nil) + em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)} + + if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) { + t.Error("no entry should be success (resisted)") + } + if IsDaySuccessful("2026-03-01", em, h) { + t.Error("entry should be failure") + } +} + +func TestIsDaySuccessful_CounterPositiveNoTarget(t *testing.T) { + h := habit(value_objects.HabitTypeCounter, false, nil) + + if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) { + t.Error("entry should be success") + } + if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) { + t.Error("no entry should be failure") + } +} + +func TestIsDaySuccessful_CounterPositiveWithTarget(t *testing.T) { + h := habit(value_objects.HabitTypeCounter, false, floatPtr(8)) + + cases := []struct { + name string + value *float64 + has bool + success bool + }{ + {"value >= target", floatPtr(10), true, true}, + {"value == target", floatPtr(8), true, true}, + {"value < target", floatPtr(3), true, false}, + {"no entry", nil, false, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + em := map[string]*entities.HabitEntry{} + if tc.has { + em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value) + } + if IsDaySuccessful("2026-03-01", em, h) != tc.success { + t.Errorf("Expected %v", tc.success) + } + }) + } +} + +func TestIsDaySuccessful_CounterNegativeNoTarget(t *testing.T) { + h := habit(value_objects.HabitTypeCounter, true, nil) + + if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) { + t.Error("no entry should be success") + } + if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) { + t.Error("entry should be failure") + } +} + +func TestIsDaySuccessful_CounterNegativeWithTarget(t *testing.T) { + h := habit(value_objects.HabitTypeCounter, true, floatPtr(2)) + + cases := []struct { + name string + value *float64 + has bool + success bool + }{ + {"no entry", nil, false, true}, + {"within limit", floatPtr(1), true, true}, + {"at limit", floatPtr(2), true, true}, + {"over limit", floatPtr(5), true, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + em := map[string]*entities.HabitEntry{} + if tc.has { + em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value) + } + if IsDaySuccessful("2026-03-01", em, h) != tc.success { + t.Errorf("Expected %v", tc.success) + } + }) + } +} + +func TestIsDaySuccessful_ValuePositiveNoTarget(t *testing.T) { + h := habit(value_objects.HabitTypeValue, false, nil) + + if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(72))}, h) { + t.Error("entry should be success") + } + if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) { + t.Error("no entry should be failure") + } +} + +func TestIsDaySuccessful_ValuePositiveWithTarget(t *testing.T) { + h := habit(value_objects.HabitTypeValue, false, floatPtr(7)) + + if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(8))}, h) { + t.Error("value >= target should be success") + } + if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) { + t.Error("value < target should be failure") + } +} + +func TestIsDaySuccessful_ValueNegativeNoTarget(t *testing.T) { + h := habit(value_objects.HabitTypeValue, true, nil) + + if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) { + t.Error("no entry should be success") + } + if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) { + t.Error("entry should be failure") + } +} + +func TestIsDaySuccessful_ValueNegativeWithTarget(t *testing.T) { + h := habit(value_objects.HabitTypeValue, true, floatPtr(70)) + + cases := []struct { + name string + value *float64 + has bool + success bool + }{ + {"below target", floatPtr(68), true, true}, + {"above target", floatPtr(75), true, false}, + {"no entry (didnt track)", nil, false, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + em := map[string]*entities.HabitEntry{} + if tc.has { + em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value) + } + if IsDaySuccessful("2026-03-01", em, h) != tc.success { + t.Errorf("Expected %v", tc.success) + } + }) + } +} + +// --- CalculateStreaks --- + +func TestStreaks_DailyBooleanPositive(t *testing.T) { + h := habit(value_objects.HabitTypeBoolean, false, nil) + + t.Run("3 consecutive days", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 1), nil), + makeEntry(dt(2026, 3, 2), nil), + makeEntry(dt(2026, 3, 3), nil), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 3)) + if r.Current != 3 || r.Longest != 3 { + t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest) + } + }) + + t.Run("gap finds longest and current separately", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 1), nil), + makeEntry(dt(2026, 3, 2), nil), + makeEntry(dt(2026, 3, 3), nil), + // gap Mar 4 + makeEntry(dt(2026, 3, 5), nil), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 5)) + if r.Current != 1 || r.Longest != 3 { + t.Errorf("Expected current=1 longest=3, got current=%d longest=%d", r.Current, r.Longest) + } + }) + + t.Run("today not completed doesnt break streak", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 1), nil), + makeEntry(dt(2026, 3, 2), nil), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 3)) + if r.Current != 2 || r.Longest != 2 { + t.Errorf("Expected current=2 longest=2, got current=%d longest=%d", r.Current, r.Longest) + } + }) + + t.Run("missed yesterday breaks streak", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 1), nil), + makeEntry(dt(2026, 3, 2), nil), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 4)) + if r.Current != 0 || r.Longest != 2 { + t.Errorf("Expected current=0 longest=2, got current=%d longest=%d", r.Current, r.Longest) + } + }) +} + +func TestStreaks_DailyBooleanNegative(t *testing.T) { + h := habit(value_objects.HabitTypeBoolean, true, nil) + h.CreatedAt = dt(2026, 3, 1) + + t.Run("3 days no entries is 3 streak", func(t *testing.T) { + r := CalculateStreaks(nil, h, dt(2026, 3, 3)) + if r.Current != 3 || r.Longest != 3 { + t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest) + } + }) + + t.Run("entry breaks streak", func(t *testing.T) { + entries := []*entities.HabitEntry{makeEntry(dt(2026, 3, 2), nil)} + r := CalculateStreaks(entries, h, dt(2026, 3, 3)) + if r.Current != 1 || r.Longest != 1 { + t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest) + } + }) +} + +func TestStreaks_WeeklyBooleanPositive(t *testing.T) { + h := &entities.Habit{ + Type: value_objects.HabitTypeBoolean, + Frequency: value_objects.FrequencyWeekly, + SpecificDays: []int{1, 3, 5}, + CreatedAt: dt(2026, 3, 1), + } + + t.Run("3 consecutive scheduled days", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 2), nil), + makeEntry(dt(2026, 3, 4), nil), + makeEntry(dt(2026, 3, 6), nil), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 6)) + if r.Current != 3 || r.Longest != 3 { + t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest) + } + }) + + t.Run("missed Wednesday breaks streak", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 2), nil), + makeEntry(dt(2026, 3, 6), nil), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 6)) + if r.Current != 1 || r.Longest != 1 { + t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest) + } + }) +} + +func TestStreaks_CounterWithTarget(t *testing.T) { + h := habit(value_objects.HabitTypeCounter, false, floatPtr(8)) + + t.Run("all meet target", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 1), floatPtr(8)), + makeEntry(dt(2026, 3, 2), floatPtr(10)), + makeEntry(dt(2026, 3, 3), floatPtr(9)), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 3)) + if r.Current != 3 { + t.Errorf("Expected current=3, got %d", r.Current) + } + }) +} + +func TestStreaks_WeeklyCounterNegativeWithTarget(t *testing.T) { + h := &entities.Habit{ + Type: value_objects.HabitTypeCounter, + Frequency: value_objects.FrequencyWeekly, + SpecificDays: []int{1, 5}, + IsNegative: true, + TargetValue: floatPtr(2), + CreatedAt: dt(2026, 3, 1), + } + + t.Run("within limit and no entry both count as success", func(t *testing.T) { + entries := []*entities.HabitEntry{ + makeEntry(dt(2026, 3, 2), floatPtr(1)), + makeEntry(dt(2026, 3, 9), floatPtr(5)), + } + r := CalculateStreaks(entries, h, dt(2026, 3, 13)) + if r.Current != 1 || r.Longest != 2 { + t.Errorf("Expected current=1 longest=2, got current=%d longest=%d", r.Current, r.Longest) + } + }) +} diff --git a/internal/infrastructure/http/stats_integration_test.go b/internal/infrastructure/http/stats_integration_test.go index 888c5be..1e64274 100644 --- a/internal/infrastructure/http/stats_integration_test.go +++ b/internal/infrastructure/http/stats_integration_test.go @@ -47,7 +47,7 @@ func TestHabitStatsFlow(t *testing.T) { today := time.Now().UTC().Format("2006-01-02") - t.Run("Stats after marking habit once", func(t *testing.T) { + t.Run("Stats after marking habit today", func(t *testing.T) { markReq := MarkHabitRequest{ ScheduledDate: today, } @@ -69,7 +69,7 @@ func TestHabitStatsFlow(t *testing.T) { t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions) } if stats.CurrentStreak != 1 { - t.Errorf("Expected current streak of 1, got %d", stats.CurrentStreak) + t.Errorf("Expected current streak of 1 (today completed counts), got %d", stats.CurrentStreak) } if stats.LongestStreak != 1 { t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)