2026-01-28 10:08:48 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-01-28 10:32:05 +08:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|