package service import ( "database/sql" "errors" "time" "github.com/daniil/homelab-api/internal/model" "github.com/daniil/homelab-api/internal/repository" ) var ErrFutureDate = errors.New("cannot log habit for future date") var ErrAlreadyLogged = errors.New("habit already logged for this date") type HabitService struct { habitRepo *repository.HabitRepository freezeRepo *repository.HabitFreezeRepository } func NewHabitService(habitRepo *repository.HabitRepository, freezeRepo *repository.HabitFreezeRepository) *HabitService { return &HabitService{ habitRepo: habitRepo, freezeRepo: freezeRepo, } } func (s *HabitService) Create(userID int64, req *model.CreateHabitRequest) (*model.Habit, error) { habit := &model.Habit{ UserID: userID, Name: req.Name, Description: req.Description, Color: defaultString(req.Color, "#6366f1"), Icon: defaultString(req.Icon, "check"), Frequency: defaultString(req.Frequency, "daily"), TargetDays: req.TargetDays, TargetCount: defaultInt(req.TargetCount, 1), } if req.ReminderTime != nil && *req.ReminderTime != "" { habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true} } // Handle start_date - default to today if not provided if req.StartDate != nil && *req.StartDate != "" { parsed, err := time.Parse("2006-01-02", *req.StartDate) if err == nil { habit.StartDate = sql.NullTime{Time: parsed, Valid: true} } } else { // Default to today habit.StartDate = sql.NullTime{Time: time.Now().Truncate(24 * time.Hour), Valid: true} } if err := s.habitRepo.Create(habit); err != nil { return nil, err } habit.ProcessForJSON() return habit, nil } func (s *HabitService) Get(id, userID int64) (*model.Habit, error) { return s.habitRepo.GetByID(id, userID) } func (s *HabitService) List(userID int64, includeArchived bool) ([]model.Habit, error) { habits, err := s.habitRepo.ListByUser(userID, includeArchived) if err != nil { return nil, err } if habits == nil { habits = []model.Habit{} } return habits, nil } func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) (*model.Habit, error) { habit, err := s.habitRepo.GetByID(id, userID) if err != nil { return nil, err } if req.Name != nil { habit.Name = *req.Name } if req.Description != nil { habit.Description = *req.Description } if req.Color != nil { habit.Color = *req.Color } if req.Icon != nil { habit.Icon = *req.Icon } if req.Frequency != nil { habit.Frequency = *req.Frequency } if req.TargetDays != nil { habit.TargetDays = req.TargetDays } if req.TargetCount != nil { habit.TargetCount = *req.TargetCount } if req.ReminderTime != nil { if *req.ReminderTime == "" { habit.ReminderTime = sql.NullString{Valid: false} } else { habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true} } } if req.StartDate != nil { if *req.StartDate == "" { habit.StartDate = sql.NullTime{Valid: false} } else { parsed, err := time.Parse("2006-01-02", *req.StartDate) if err == nil { habit.StartDate = sql.NullTime{Time: parsed, Valid: true} } } } if req.IsArchived != nil { habit.IsArchived = *req.IsArchived } if err := s.habitRepo.Update(habit); err != nil { return nil, err } habit.ProcessForJSON() return habit, nil } func (s *HabitService) Delete(id, userID int64) error { return s.habitRepo.Delete(id, userID) } func (s *HabitService) Log(habitID, userID int64, req *model.LogHabitRequest) (*model.HabitLog, error) { // Verify habit exists and belongs to user if _, err := s.habitRepo.GetByID(habitID, userID); err != nil { return nil, err } today := time.Now().Truncate(24 * time.Hour) date := today if req.Date != "" { parsed, err := time.Parse("2006-01-02", req.Date) if err != nil { return nil, err } date = parsed.Truncate(24 * time.Hour) } // Validate: cannot log for future date if date.After(today) { return nil, ErrFutureDate } // Check if already logged for this date alreadyLogged, err := s.habitRepo.IsHabitCompletedOnDate(habitID, userID, date) if err != nil { return nil, err } if alreadyLogged { return nil, ErrAlreadyLogged } log := &model.HabitLog{ HabitID: habitID, UserID: userID, Date: date, Count: defaultInt(req.Count, 1), Note: req.Note, } if err := s.habitRepo.CreateLog(log); err != nil { return nil, err } return log, nil } func (s *HabitService) GetLogs(habitID, userID int64, days int) ([]model.HabitLog, error) { // Verify habit exists and belongs to user if _, err := s.habitRepo.GetByID(habitID, userID); err != nil { return nil, err } to := time.Now() from := to.AddDate(0, 0, -days) logs, err := s.habitRepo.GetLogs(habitID, userID, from, to) if err != nil { return nil, err } if logs == nil { logs = []model.HabitLog{} } return logs, nil } func (s *HabitService) DeleteLog(logID, userID int64) error { return s.habitRepo.DeleteLog(logID, userID) } func (s *HabitService) GetHabitStats(habitID, userID int64) (*model.HabitStats, error) { // Verify habit exists and belongs to user habit, err := s.habitRepo.GetByID(habitID, userID) if err != nil { return nil, err } stats, err := s.habitRepo.GetStats(habitID, userID) if err != nil { return nil, err } // Recalculate completion percentage with frozen days excluded stats.CompletionPct = s.calculateCompletionPctWithFreezes(habit, stats.TotalLogs) return stats, nil } func (s *HabitService) GetOverallStats(userID int64) (*model.OverallStats, error) { habits, err := s.habitRepo.ListByUser(userID, false) if err != nil { return nil, err } allHabits, err := s.habitRepo.ListByUser(userID, true) if err != nil { return nil, err } todayLogs, err := s.habitRepo.GetUserLogsForDate(userID, time.Now().Truncate(24*time.Hour)) if err != nil { return nil, err } return &model.OverallStats{ TotalHabits: len(allHabits), ActiveHabits: len(habits), TodayCompleted: len(todayLogs), }, nil } // calculateCompletionPctWithFreezes calculates completion % excluding frozen days func (s *HabitService) calculateCompletionPctWithFreezes(habit *model.Habit, totalLogs int) float64 { if totalLogs == 0 { return 0 } // Use start_date if set, otherwise use created_at var startDate time.Time if habit.StartDate.Valid { startDate = habit.StartDate.Time.Truncate(24 * time.Hour) } else { startDate = habit.CreatedAt.Truncate(24 * time.Hour) } today := time.Now().Truncate(24 * time.Hour) // Get frozen days count for this habit frozenDays, err := s.freezeRepo.CountFrozenDaysInRange(habit.ID, startDate, today) if err != nil { frozenDays = 0 } expectedCount := 0 // For interval habits, calculate expected differently if (habit.Frequency == "interval" || habit.Frequency == "custom") && habit.TargetCount > 0 { // Expected = (days since start - frozen days) / interval + 1 totalDays := int(today.Sub(startDate).Hours()/24) + 1 - frozenDays if totalDays <= 0 { return 100 } expectedCount = (totalDays / habit.TargetCount) + 1 } else { for d := startDate; !d.After(today); d = d.AddDate(0, 0, 1) { // Check if this day is frozen frozen, _ := s.freezeRepo.IsHabitFrozenOnDate(habit.ID, d) if frozen { continue } if habit.Frequency == "daily" { expectedCount++ } else if habit.Frequency == "weekly" && len(habit.TargetDays) > 0 { weekday := int(d.Weekday()) if weekday == 0 { weekday = 7 } for _, td := range habit.TargetDays { if td == weekday { expectedCount++ break } } } else { expectedCount++ } } } if expectedCount == 0 { return 100 } pct := float64(totalLogs) / float64(expectedCount) * 100 if pct > 100 { pct = 100 } return pct } func defaultString(val, def string) string { if val == "" { return def } return val } func defaultInt(val, def int) int { if val == 0 { return def } return val }