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:
+3
-1
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user