package repository import ( "database/sql" "errors" "time" "github.com/daniil/homelab-api/internal/model" "github.com/jmoiron/sqlx" "github.com/lib/pq" ) var ErrHabitNotFound = errors.New("habit not found") var ErrLogNotFound = errors.New("log not found") type HabitRepository struct { db *sqlx.DB } func NewHabitRepository(db *sqlx.DB) *HabitRepository { return &HabitRepository{db: db} } func (r *HabitRepository) Create(habit *model.Habit) error { query := ` INSERT INTO habits (user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, start_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at` targetDays := pq.Array(habit.TargetDays) if len(habit.TargetDays) == 0 { targetDays = pq.Array([]int{0, 1, 2, 3, 4, 5, 6}) } return r.db.QueryRow(query, habit.UserID, habit.Name, habit.Description, habit.Color, habit.Icon, habit.Frequency, targetDays, habit.TargetCount, habit.ReminderTime, habit.StartDate, ).Scan(&habit.ID, &habit.CreatedAt, &habit.UpdatedAt) } func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) { var habit model.Habit var targetDays pq.Int64Array query := ` SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, start_date, is_archived, created_at, updated_at FROM habits WHERE id = $1 AND user_id = $2` err := r.db.QueryRow(query, id, userID).Scan( &habit.ID, &habit.UserID, &habit.Name, &habit.Description, &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, &habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrHabitNotFound } return nil, err } habit.TargetDays = make([]int, len(targetDays)) for i, v := range targetDays { habit.TargetDays[i] = int(v) } habit.ProcessForJSON() return &habit, nil } func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]model.Habit, error) { query := ` SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, start_date, is_archived, created_at, updated_at FROM habits WHERE user_id = $1` if !includeArchived { query += " AND is_archived = FALSE" } query += " ORDER BY created_at DESC" rows, err := r.db.Query(query, userID) if err != nil { return nil, err } defer rows.Close() var habits []model.Habit for rows.Next() { var habit model.Habit var targetDays pq.Int64Array if err := rows.Scan( &habit.ID, &habit.UserID, &habit.Name, &habit.Description, &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, &habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, ); err != nil { return nil, err } habit.TargetDays = make([]int, len(targetDays)) for i, v := range targetDays { habit.TargetDays[i] = int(v) } habit.ProcessForJSON() habits = append(habits, habit) } return habits, nil } func (r *HabitRepository) GetHabitsWithReminder(reminderTime string, weekday int) ([]model.Habit, error) { query := ` SELECT h.id, h.user_id, h.name, h.description, h.color, h.icon, h.frequency, h.target_days, h.target_count, h.reminder_time, h.start_date, h.is_archived, h.created_at, h.updated_at FROM habits h JOIN users u ON h.user_id = u.id WHERE h.reminder_time = $1 AND h.is_archived = false AND u.telegram_chat_id IS NOT NULL AND u.notifications_enabled = true AND ( h.frequency = 'daily' OR (h.frequency = 'weekly' AND $2 = ANY(h.target_days)) OR h.frequency = 'interval' OR h.frequency = 'custom' )` rows, err := r.db.Query(query, reminderTime, weekday) if err != nil { return nil, err } defer rows.Close() var habits []model.Habit for rows.Next() { var habit model.Habit var targetDays pq.Int64Array if err := rows.Scan( &habit.ID, &habit.UserID, &habit.Name, &habit.Description, &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, &habit.TargetCount, &habit.ReminderTime, &habit.StartDate, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, ); err != nil { return nil, err } habit.TargetDays = make([]int, len(targetDays)) for i, v := range targetDays { habit.TargetDays[i] = int(v) } habit.ProcessForJSON() habits = append(habits, habit) } return habits, nil } // ShouldShowIntervalHabitToday checks if an interval habit should be shown today func (r *HabitRepository) ShouldShowIntervalHabitToday(habitID, userID int64, intervalDays int, startDate sql.NullTime) (bool, error) { // Get the last log date for this habit var lastLogDate sql.NullTime err := r.db.Get(&lastLogDate, ` SELECT MAX(date) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 `, habitID, userID) if err != nil && err != sql.ErrNoRows { return false, err } today := time.Now().Truncate(24 * time.Hour) // If no logs exist, check if today >= start_date (show on start_date) if !lastLogDate.Valid { if startDate.Valid { return !today.Before(startDate.Time.Truncate(24*time.Hour)), nil } return true, nil } // Calculate days since last log lastLog := lastLogDate.Time.Truncate(24 * time.Hour) daysSinceLastLog := int(today.Sub(lastLog).Hours() / 24) return daysSinceLastLog >= intervalDays, nil } func (r *HabitRepository) Update(habit *model.Habit) error { query := ` UPDATE habits SET name = $2, description = $3, color = $4, icon = $5, frequency = $6, target_days = $7, target_count = $8, reminder_time = $9, start_date = $10, is_archived = $11, updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND user_id = $12 RETURNING updated_at` return r.db.QueryRow(query, habit.ID, habit.Name, habit.Description, habit.Color, habit.Icon, habit.Frequency, pq.Array(habit.TargetDays), habit.TargetCount, habit.ReminderTime, habit.StartDate, habit.IsArchived, habit.UserID, ).Scan(&habit.UpdatedAt) } func (r *HabitRepository) Delete(id, userID int64) error { query := `DELETE FROM habits WHERE id = $1 AND user_id = $2` result, err := r.db.Exec(query, id, userID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrHabitNotFound } return nil } // Logs func (r *HabitRepository) CreateLog(log *model.HabitLog) error { query := ` INSERT INTO habit_logs (habit_id, user_id, date, count, note) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (habit_id, date) DO UPDATE SET count = habit_logs.count + EXCLUDED.count, note = EXCLUDED.note RETURNING id, created_at` return r.db.QueryRow(query, log.HabitID, log.UserID, log.Date, log.Count, log.Note). Scan(&log.ID, &log.CreatedAt) } func (r *HabitRepository) GetLogs(habitID, userID int64, from, to time.Time) ([]model.HabitLog, error) { query := ` SELECT id, habit_id, user_id, date, count, note, created_at FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3 AND date <= $4 ORDER BY date DESC` var logs []model.HabitLog if err := r.db.Select(&logs, query, habitID, userID, from, to); err != nil { return nil, err } return logs, nil } func (r *HabitRepository) DeleteLog(logID, userID int64) error { query := `DELETE FROM habit_logs WHERE id = $1 AND user_id = $2` result, err := r.db.Exec(query, logID, userID) if err != nil { return err } rows, _ := result.RowsAffected() if rows == 0 { return ErrLogNotFound } return nil } func (r *HabitRepository) GetUserLogsForDate(userID int64, date time.Time) ([]model.HabitLog, error) { query := ` SELECT id, habit_id, user_id, date, count, note, created_at FROM habit_logs WHERE user_id = $1 AND date = $2` var logs []model.HabitLog if err := r.db.Select(&logs, query, userID, date); err != nil { return nil, err } return logs, nil } func (r *HabitRepository) IsHabitCompletedToday(habitID, userID int64) (bool, error) { today := time.Now().Format("2006-01-02") var count int err := r.db.Get(&count, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date = $3`, habitID, userID, today) return count > 0, err } func (r *HabitRepository) GetStats(habitID, userID int64) (*model.HabitStats, error) { stats := &model.HabitStats{HabitID: habitID} // Get habit info habit, err := r.GetByID(habitID, userID) if err != nil { return nil, err } // Total logs r.db.Get(&stats.TotalLogs, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2`, habitID, userID) // This week (Monday-based) now := time.Now() weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 } weekStart := now.AddDate(0, 0, -(weekday - 1)).Truncate(24 * time.Hour) r.db.Get(&stats.ThisWeek, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, weekStart) // This month monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) r.db.Get(&stats.ThisMonth, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date >= $3`, habitID, userID, monthStart) // Streaks calculation (respecting target_days and interval) stats.CurrentStreak, stats.LongestStreak = r.calculateStreaksWithDays(habitID, userID, habit.Frequency, habit.TargetDays, habit.TargetCount) // Completion percentage since habit creation/start_date stats.CompletionPct = r.calculateCompletionPct(habit, stats.TotalLogs) return stats, nil } // calculateStreaksWithDays counts consecutive completions on expected days func (r *HabitRepository) calculateStreaksWithDays(habitID, userID int64, frequency string, targetDays []int, targetCount int) (current, longest int) { query := `SELECT date FROM habit_logs WHERE habit_id = $1 AND user_id = $2 ORDER BY date DESC` var logDates []time.Time if err := r.db.Select(&logDates, query, habitID, userID); err != nil || len(logDates) == 0 { return 0, 0 } // Convert log dates to map for quick lookup logMap := make(map[string]bool) for _, d := range logDates { logMap[d.Format("2006-01-02")] = true } // For interval habits, calculate streaks differently if (frequency == "interval" || frequency == "custom") && targetCount > 0 { return r.calculateIntervalStreaks(logDates, targetCount) } // Generate expected days from today backwards today := time.Now().Truncate(24 * time.Hour) expectedDays := r.getExpectedDays(today, frequency, targetDays, 365) // Look back up to 1 year if len(expectedDays) == 0 { return 0, 0 } // Current streak: count from most recent expected day current = 0 for _, day := range expectedDays { if logMap[day.Format("2006-01-02")] { current++ } else { break } } // Longest streak longest = 0 streak := 0 for _, day := range expectedDays { if logMap[day.Format("2006-01-02")] { streak++ if streak > longest { longest = streak } } else { streak = 0 } } return current, longest } // calculateIntervalStreaks calculates streaks for interval-based habits func (r *HabitRepository) calculateIntervalStreaks(logDates []time.Time, intervalDays int) (current, longest int) { if len(logDates) == 0 { return 0, 0 } today := time.Now().Truncate(24 * time.Hour) // Check if the most recent log is within the interval window from today lastLogDate := logDates[0].Truncate(24 * time.Hour) daysSinceLastLog := int(today.Sub(lastLogDate).Hours() / 24) // Current streak: if we're within interval, count consecutive logs that are within interval of each other current = 0 if daysSinceLastLog < intervalDays { current = 1 for i := 1; i < len(logDates); i++ { prevDate := logDates[i-1].Truncate(24 * time.Hour) currDate := logDates[i].Truncate(24 * time.Hour) daysBetween := int(prevDate.Sub(currDate).Hours() / 24) // If the gap is exactly the interval (or less, if done early), continue streak if daysBetween <= intervalDays { current++ } else { break } } } // Longest streak calculation longest = 1 streak := 1 for i := 1; i < len(logDates); i++ { prevDate := logDates[i-1].Truncate(24 * time.Hour) currDate := logDates[i].Truncate(24 * time.Hour) daysBetween := int(prevDate.Sub(currDate).Hours() / 24) if daysBetween <= intervalDays { streak++ if streak > longest { longest = streak } } else { streak = 1 } } return current, longest } // getExpectedDays returns a list of days when the habit should be done, sorted descending func (r *HabitRepository) getExpectedDays(from time.Time, frequency string, targetDays []int, maxDays int) []time.Time { var result []time.Time for i := 0; i < maxDays; i++ { day := from.AddDate(0, 0, -i) if frequency == "daily" { result = append(result, day) } else if frequency == "weekly" && len(targetDays) > 0 { weekday := int(day.Weekday()) if weekday == 0 { weekday = 7 // Sunday = 7 } for _, td := range targetDays { if td == weekday { result = append(result, day) break } } } else { result = append(result, day) } } return result } // calculateCompletionPct calculates completion percentage since habit start_date (or created_at) func (r *HabitRepository) calculateCompletionPct(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) expectedCount := 0 // For interval habits, calculate expected differently if (habit.Frequency == "interval" || habit.Frequency == "custom") && habit.TargetCount > 0 { // Expected = (days since start) / interval + 1 daysSinceStart := int(today.Sub(startDate).Hours()/24) + 1 expectedCount = (daysSinceStart / habit.TargetCount) + 1 } else { for d := startDate; !d.After(today); d = d.AddDate(0, 0, 1) { 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 0 } return float64(totalLogs) / float64(expectedCount) * 100 } func (r *HabitRepository) IsHabitCompletedOnDate(habitID, userID int64, date time.Time) (bool, error) { dateStr := date.Format("2006-01-02") var count int err := r.db.Get(&count, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date = $3`, habitID, userID, dateStr) return count > 0, err }