diff --git a/internal/bot/calendar.go b/internal/bot/calendar.go index 4151298..28c36ed 100644 --- a/internal/bot/calendar.go +++ b/internal/bot/calendar.go @@ -510,7 +510,7 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, } t := time.Unix(event.OccurredAt, 0).In(loc) newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc) - date := t.Format(DateLayout) + date := newTime.Format(DateLayout) // Reject if the new time collides with another event's timestamp events, err := h.DB.EventsForDayByDate(user.ID, date) @@ -563,6 +563,8 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, h.sendDayView(ctx, chatID, msgID, user, date, loc, false) return } + h.recalcDayWorkSeconds(user.ID, date, loc) + h.recalcAggregates(user.ID, loc) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } @@ -635,6 +637,8 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in } } + h.recalcDayWorkSeconds(user.ID, date, loc) + h.recalcAggregates(user.ID, loc) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } @@ -707,6 +711,8 @@ func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user return } + h.recalcDayWorkSeconds(user.ID, date, loc) + h.recalcAggregates(user.ID, loc) h.sendDayView(ctx, chatID, msgID, user, date, loc, false) } diff --git a/internal/bot/clock.go b/internal/bot/clock.go index 9798cfa..d6e1333 100644 --- a/internal/bot/clock.go +++ b/internal/bot/clock.go @@ -70,10 +70,10 @@ func (h *Handler) clockOut(chatID int64) (string, error) { today := now.Format(DateLayout) isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday - streak, _ := h.updateStreak(user.ID, today, true) - xp, leveledUp, newLevel, _ := h.computeAndAwardXP(user.ID, sessionWork, streak, today) stats, _ := h.DB.GetOrCreateRPGStats(user.ID) - totalHours := float64(h.totalWorkSeconds(user.ID, loc)) / secondsPerHour + h.updateStreak(stats, user.ID, today) + xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today) + totalHours := float64(stats.TotalWorkSeconds) / secondsPerHour unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours) text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel) diff --git a/internal/bot/rpg.go b/internal/bot/rpg.go index 6fb693d..ac8d319 100644 --- a/internal/bot/rpg.go +++ b/internal/bot/rpg.go @@ -47,14 +47,10 @@ func streakBonus(streak int) int64 { return b } -// computeAndAwardXP calculates XP for a work session and updates the DB. -// Returns the new total XP and whether the user leveled up. -func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) { - stats, err := h.DB.GetOrCreateRPGStats(userID) - if err != nil { - return 0, false, 0, err - } - +// computeAndAwardXP calculates XP for a work session and persists it. +// stats must already have the streak updated before calling. +// Returns the new total XP, whether the user leveled up, and the new level. +func (h *Handler) computeAndAwardXP(stats *db.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) { baseXP := xpForWorkSeconds(workSeconds) bonus := streakBonus(streak) totalXP := baseXP + bonus @@ -81,25 +77,11 @@ func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, } // updateStreak advances or resets the user's streak based on today's activity. -// Returns the updated streak count. -func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (int, error) { - stats, err := h.DB.GetOrCreateRPGStats(userID) - if err != nil { - return 0, err - } - - if !hasWorkToday { - stats.CurrentStreak = 0 - if err := h.DB.UpdateRPGStats(stats); err != nil { - return 0, err - } - return 0, nil - } - - // Parse yesterday to check continuity +// Only called from clockOut — incremental update, not a full scan. +func (h *Handler) updateStreak(stats *db.RPGStats, userID int64, date string) { t, err := time.Parse(DateLayout, date) if err != nil { - return 0, err + return } yesterday := t.AddDate(0, 0, -1).Format(DateLayout) @@ -115,12 +97,91 @@ func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (in if stats.CurrentStreak > stats.LongestStreak { stats.LongestStreak = stats.CurrentStreak } +} - if err := h.DB.UpdateRPGStats(stats); err != nil { - return 0, err +// recalcDayWorkSeconds recomputes work_seconds for a single day from events and updates daily_xp. +func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) { + events, err := h.DB.EventsForDayByDate(userID, date) + if err != nil { + return + } + day, err := h.DB.GetDay(userID, date) + if err != nil { + return + } + y, m, d := parseGregorianDateKey(date) + dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix() + dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() + totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) + if err := h.DB.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil { + slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err) + } +} + +// recalcAggregates recomputes total_work_seconds and streak from scratch after event edits. +func (h *Handler) recalcAggregates(userID int64, loc *time.Location) { + total, err := h.DB.SumDailyWorkSeconds(userID) + if err != nil { + slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err) + return } - return stats.CurrentStreak, nil + stats, err := h.DB.GetOrCreateRPGStats(userID) + if err != nil { + slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err) + return + } + stats.TotalWorkSeconds = total + + // Recompute streak from scratch + dates, err := h.DB.GetDatesWithEvents(userID) + if err == nil && len(dates) > 0 { + dateSet := make(map[string]struct{}, len(dates)) + for _, d := range dates { + dateSet[d] = struct{}{} + } + today := time.Now().In(loc).Format(DateLayout) + + // Current streak + stats.CurrentStreak = 0 + if _, ok := dateSet[today]; ok { + stats.CurrentStreak = 1 + for i := 1; ; i++ { + prev := time.Now().In(loc).AddDate(0, 0, -i).Format(DateLayout) + if _, ok := dateSet[prev]; ok { + stats.CurrentStreak++ + } else { + break + } + } + } + + // Longest streak + stats.LongestStreak = 0 + run := 1 + for i := 1; i < len(dates); i++ { + prev, _ := time.Parse(DateLayout, dates[i-1]) + curr, _ := time.Parse(DateLayout, dates[i]) + if curr.Sub(prev).Hours() == 24 { + run++ + } else { + if run > stats.LongestStreak { + stats.LongestStreak = run + } + run = 1 + } + } + if run > stats.LongestStreak { + stats.LongestStreak = run + } + } else { + stats.CurrentStreak = 0 + stats.LongestStreak = 0 + } + + if err := h.DB.UpdateRPGStats(stats); err != nil { + slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err) + } } // checkAchievements evaluates and unlocks any newly earned achievements. @@ -237,32 +298,6 @@ func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *db.RPGSt } } -// totalWorkSeconds computes actual total work seconds from all events for the user. -// Calculated at runtime to stay in sync with event edits. -func (h *Handler) totalWorkSeconds(userID int64, loc *time.Location) int64 { - days, err := h.DB.GetUserDaysInRange(userID, "2000-01-01", "2100-01-01") - if err != nil { - return 0 - } - - var total int64 - for _, day := range days { - if day.IsDayOff { - continue - } - events, err := h.DB.EventsForDayByDayID(day.ID) - if err != nil || len(events) == 0 { - continue - } - y, m, d := parseGregorianDateKey(day.Date) - dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix() - dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() - totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) - total += totals.TotalSeconds - } - return total -} - // buildRPGStatus returns a formatted string showing the user's RPG stats. func (h *Handler) buildRPGStatus(userID int64, loc *time.Location) string { stats, err := h.DB.GetOrCreateRPGStats(userID) @@ -280,7 +315,7 @@ func (h *Handler) buildRPGStatus(userID int64, loc *time.Location) string { b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded)) b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak)) - hoursTotal := float64(h.totalWorkSeconds(userID, loc)) / secondsPerHour + hoursTotal := float64(stats.TotalWorkSeconds) / secondsPerHour b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal)) // Show achievements count diff --git a/internal/db/store.go b/internal/db/store.go index a611c9c..1178cd8 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -593,7 +593,8 @@ func (s *Store) UpdateRPGStats(st *RPGStats) error { // -- Daily XP -- -// UpsertDailyXP inserts or updates the daily XP record for a user/date. +// UpsertDailyXP inserts or updates the daily XP and work seconds for a user/date. +// xpEarned and workSeconds are accumulated on conflict. func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error { _, err := s.db.Exec(` INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds) @@ -605,6 +606,26 @@ func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds i return err } +// SetDailyWorkSeconds overwrites the work_seconds for a specific day (used during recalc on edit). +func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error { + _, err := s.db.Exec(` + INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds) + VALUES (?, ?, 0, ?) + ON CONFLICT(user_id, date) DO UPDATE SET + work_seconds = ? + `, userID, date, workSeconds, workSeconds) + return err +} + +// SumDailyWorkSeconds returns the sum of work_seconds across all days for the user. +func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) { + var total int64 + err := s.db.QueryRow( + "SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID, + ).Scan(&total) + return total, err +} + // GetDailyXP returns the daily XP record for a user/date. func (s *Store) GetDailyXP(userID int64, date string) (*DailyXP, error) { row := s.db.QueryRow(` @@ -890,6 +911,31 @@ func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]D return days, rows.Err() } +// GetDatesWithEvents returns all distinct dates (from the days table) on which +// the user has at least one event, ordered ascending. +func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) { + rows, err := s.db.Query(` + SELECT DISTINCT d.date + FROM days d + JOIN events e ON e.day_id = d.id + WHERE d.user_id = ? + ORDER BY d.date ASC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var dates []string + for rows.Next() { + var date string + if err := rows.Scan(&date); err != nil { + return nil, err + } + dates = append(dates, date) + } + return dates, rows.Err() +} + // CreateEvent inserts a new event record (typically "in" or "out"). func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error { _, err := s.db.Exec(