Add habit statistics feature

- Implement GetHabitStatsHandler with streak calculations
- Calculate current streak, longest streak, and completion rate
- Track completions this week and this month
- Add stats HTTP handler and route at /api/v1/stats/habits/{id}
- Add Swagger documentation for stats endpoint
This commit is contained in:
2025-11-26 18:45:27 +01:00
parent 3b69de5890
commit a0141019b4
4 changed files with 245 additions and 2 deletions
+3 -1
View File
@@ -64,6 +64,7 @@ func main() {
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
@@ -71,8 +72,9 @@ func main() {
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, jwtService)
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler)
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, jwtService)
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, statsHandlers, jwtService)
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
log.Printf("Server starting on %s", addr)
@@ -0,0 +1,170 @@
package queries
import (
"context"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"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"`
}
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 {
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)
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
for _, entry := range entries {
if entry.ScheduledDate.After(cutoff) {
count++
}
}
return count
}
+6 -1
View File
@@ -13,7 +13,7 @@ import (
_ "apocapoc-api/docs"
)
func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, jwtService *auth.JWTService) *chi.Mux {
func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, jwtService *auth.JWTService) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
@@ -55,5 +55,10 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
r.Delete("/{id}/entries/{date}", habitHandlers.UnmarkHabit)
})
r.Route("/api/v1/stats", func(r chi.Router) {
r.Use(AuthMiddleware(jwtService))
r.Get("/habits/{id}", statsHandlers.GetHabitStats)
})
return r
}
@@ -0,0 +1,66 @@
package http
import (
"net/http"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/shared/errors"
"github.com/go-chi/chi/v5"
)
type StatsHandlers struct {
getHabitStatsHandler *queries.GetHabitStatsHandler
}
func NewStatsHandlers(
getHabitStatsHandler *queries.GetHabitStatsHandler,
) *StatsHandlers {
return &StatsHandlers{
getHabitStatsHandler: getHabitStatsHandler,
}
}
// GetHabitStats godoc
// @Summary Get habit statistics
// @Description Get statistics for a specific habit including streaks and completion rates
// @Tags stats
// @Produce json
// @Security BearerAuth
// @Param id path string true "Habit ID"
// @Success 200 {object} queries.HabitStatsDTO
// @Failure 401 {object} ErrorResponse
// @Failure 403 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /stats/habits/{id} [get]
func (h *StatsHandlers) GetHabitStats(w http.ResponseWriter, r *http.Request) {
habitID := chi.URLParam(r, "id")
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
return
}
query := queries.GetHabitStatsQuery{
HabitID: habitID,
UserID: userID,
}
stats, err := h.getHabitStatsHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to get habit stats")
return
}
respondJSON(w, http.StatusOK, stats)
}