package service import ( "time" "accounting-app/internal/models" "accounting-app/internal/repository" ) // StreakService handles business logic for user streaks type StreakService struct { repo *repository.StreakRepository } // NewStreakService creates a new StreakService instance func NewStreakService(repo *repository.StreakRepository) *StreakService { return &StreakService{repo: repo} } // GetStreakInfo returns the streak information for a user func (s *StreakService) GetStreakInfo(userID uint) (*models.StreakInfo, error) { streak, err := s.repo.GetOrCreate(userID) if err != nil { return nil, err } now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // Check if user has recorded today hasRecordToday, err := s.repo.HasTransactionOnDate(userID, today) if err != nil { return nil, err } // Determine message based on streak status var message string if hasRecordToday { if streak.CurrentStreak >= 7 { message = "太厉害了!连续记账一周以上 🔥" } else if streak.CurrentStreak >= 3 { message = "继续保持,养成好习惯!💪" } else { message = "今日已记账,明天继续哦 ✅" } } else { if streak.CurrentStreak > 0 { message = "今天还没有记账哦,别断了连续记录!" } else { message = "今天还没有记账哦,开始记录吧!" } } return &models.StreakInfo{ CurrentStreak: streak.CurrentStreak, LongestStreak: streak.LongestStreak, TotalRecordDays: streak.TotalRecordDays, HasRecordToday: hasRecordToday, Message: message, }, nil } // UpdateStreak updates the streak when a transaction is created // This should be called after a transaction is created func (s *StreakService) UpdateStreak(userID uint, transactionDate time.Time) error { streak, err := s.repo.GetOrCreate(userID) if err != nil { return err } // Normalize transaction date to start of day txDate := time.Date(transactionDate.Year(), transactionDate.Month(), transactionDate.Day(), 0, 0, 0, 0, transactionDate.Location()) now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // Only update streak for today's date or past dates (not future) if txDate.After(today) { return nil // Don't update streak for future transactions } // If this is the first record ever if streak.LastRecordDate == nil { streak.CurrentStreak = 1 streak.LongestStreak = 1 streak.TotalRecordDays = 1 streak.LastRecordDate = &txDate return s.repo.Update(streak) } lastDate := *streak.LastRecordDate lastDateNormalized := time.Date(lastDate.Year(), lastDate.Month(), lastDate.Day(), 0, 0, 0, 0, lastDate.Location()) // If already recorded on this date, no update needed if txDate.Equal(lastDateNormalized) { return nil } // If recording for a new date if txDate.After(lastDateNormalized) { daysDiff := int(txDate.Sub(lastDateNormalized).Hours() / 24) if daysDiff == 1 { // Consecutive day - increment streak streak.CurrentStreak++ } else { // Streak broken - reset to 1 streak.CurrentStreak = 1 } // Update longest streak if current is higher if streak.CurrentStreak > streak.LongestStreak { streak.LongestStreak = streak.CurrentStreak } // Increment total record days streak.TotalRecordDays++ streak.LastRecordDate = &txDate } else { // Recording for a past date - just increment total if it's a new day hasRecord, err := s.repo.HasTransactionOnDate(userID, txDate) if err != nil { return err } // This is a bit tricky - we'd need to check if this specific date already has records // For simplicity, if it's a past date and we're adding a new transaction, // we'll just increment total (this might not be 100% accurate for edits) if !hasRecord { streak.TotalRecordDays++ } } return s.repo.Update(streak) } // RecalculateStreak recalculates the entire streak from transaction history // This is useful for fixing streak data or after bulk imports func (s *StreakService) RecalculateStreak(userID uint) error { streak, err := s.repo.GetOrCreate(userID) if err != nil { return err } // Get all transaction dates for the user (last 365 days should be enough) now := time.Now() startDate := now.AddDate(-1, 0, 0) // 1 year ago dates, err := s.repo.GetTransactionDatesInRange(userID, startDate, now) if err != nil { return err } if len(dates) == 0 { // No transactions, reset streak streak.CurrentStreak = 0 streak.LongestStreak = 0 streak.TotalRecordDays = 0 streak.LastRecordDate = nil return s.repo.Update(streak) } // Calculate streaks streak.TotalRecordDays = len(dates) // Find longest streak and current streak today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) currentStreak := 1 longestStreak := 1 tempStreak := 1 for i := 1; i < len(dates); i++ { prevDate := dates[i-1] currDate := dates[i] daysDiff := int(currDate.Sub(prevDate).Hours() / 24) if daysDiff == 1 { tempStreak++ if tempStreak > longestStreak { longestStreak = tempStreak } } else { tempStreak = 1 } } // Determine current streak (must include today or yesterday) lastRecordDate := dates[len(dates)-1] lastRecordNormalized := time.Date(lastRecordDate.Year(), lastRecordDate.Month(), lastRecordDate.Day(), 0, 0, 0, 0, lastRecordDate.Location()) daysSinceLastRecord := int(today.Sub(lastRecordNormalized).Hours() / 24) if daysSinceLastRecord <= 1 { // Calculate current streak going backwards from last record currentStreak = 1 for i := len(dates) - 2; i >= 0; i-- { currDate := dates[i+1] prevDate := dates[i] daysDiff := int(currDate.Sub(prevDate).Hours() / 24) if daysDiff == 1 { currentStreak++ } else { break } } } else { // Streak is broken currentStreak = 0 } streak.CurrentStreak = currentStreak streak.LongestStreak = longestStreak streak.LastRecordDate = &lastRecordNormalized return s.repo.Update(streak) } // CheckAndResetStreak checks if streak should be reset (called daily or on app open) func (s *StreakService) CheckAndResetStreak(userID uint) error { streak, err := s.repo.GetOrCreate(userID) if err != nil { return err } if streak.LastRecordDate == nil { return nil } now := time.Now() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) lastDate := *streak.LastRecordDate lastDateNormalized := time.Date(lastDate.Year(), lastDate.Month(), lastDate.Day(), 0, 0, 0, 0, lastDate.Location()) daysDiff := int(today.Sub(lastDateNormalized).Hours() / 24) // If more than 1 day has passed without recording, reset streak if daysDiff > 1 { streak.CurrentStreak = 0 return s.repo.Update(streak) } return nil } // GetContributionData returns daily transaction counts for the past year func (s *StreakService) GetContributionData(userID uint) ([]models.DailyContribution, error) { now := time.Now() startDate := now.AddDate(-1, 0, 0) // 1 year ago return s.repo.GetDailyContribution(userID, startDate, now) }