Files
apocapoc-api/internal/application/queries/get_habit_stats.go
T
david 2b059ec334 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
2026-03-07 22:49:31 +01:00

90 lines
2.1 KiB
Go

package queries
import (
"context"
"time"
"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"`
CompletionsThisWeek int `json:"completions_this_week"`
CompletionsThisMonth int `json:"completions_this_month"`
}
type GetHabitStatsQuery struct {
HabitID string
UserID string
}
type GetHabitStatsHandler struct {
habitRepo repositories.HabitRepository
entryRepo repositories.HabitEntryRepository
}
func NewGetHabitStatsHandler(
habitRepo repositories.HabitRepository,
entryRepo repositories.HabitEntryRepository,
) *GetHabitStatsHandler {
return &GetHabitStatsHandler{
habitRepo: habitRepo,
entryRepo: entryRepo,
}
}
func (h *GetHabitStatsHandler) Handle(ctx context.Context, query GetHabitStatsQuery) (*HabitStatsDTO, error) {
habit, err := h.habitRepo.FindByID(ctx, query.HabitID)
if err != nil {
return nil, err
}
if habit.UserID != query.UserID {
return nil, errors.ErrUnauthorized
}
entries, err := h.entryRepo.FindByHabitID(ctx, query.HabitID)
if err != nil {
return nil, err
}
stats := &HabitStatsDTO{
HabitID: habit.ID,
HabitName: habit.Name,
}
if len(entries) == 0 && !habit.IsNegative {
return stats, nil
}
stats.TotalCompletions = len(entries)
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 countCompletionsInPeriod(entries []*entities.HabitEntry, days int) int {
cutoff := time.Now().UTC().AddDate(0, 0, -days)
count := 0
for _, entry := range entries {
if entry.ScheduledDate.After(cutoff) {
count++
}
}
return count
}