diff --git a/cmd/api/main.go b/cmd/api/main.go index b80e872..7d9132e 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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) diff --git a/internal/application/queries/get_habit_stats.go b/internal/application/queries/get_habit_stats.go new file mode 100644 index 0000000..5b3d094 --- /dev/null +++ b/internal/application/queries/get_habit_stats.go @@ -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 +} diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index e7376d4..9c13c50 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -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 } diff --git a/internal/infrastructure/http/stats_handlers.go b/internal/infrastructure/http/stats_handlers.go new file mode 100644 index 0000000..d85bdec --- /dev/null +++ b/internal/infrastructure/http/stats_handlers.go @@ -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) +}