refactor: restore stored aggregates with incremental update on edit

- Restore TotalWorkSeconds, CurrentStreak, LongestStreak writes in
  computeAndAwardXP and updateStreak (incremental on clock out)
- Add recalcDayWorkSeconds and recalcAggregates for event edit path
  (recompute day + total + streak from scratch when events change)
- Call recalcAggregates after editEventTimeSet, deleteEventConfirm,
  addEventDo in calendar.go
- Remove runtime computeStreaks and totalWorkSeconds (N+1 scan)
  from RPG display, burnout, and clockOut
- Add SetDailyWorkSeconds, SumDailyWorkSeconds store methods
- Clean up unused dead functions and dead code in rpg.go
This commit is contained in:
2026-06-25 02:19:40 +03:30
parent 4fcb694418
commit 5cd811e057
4 changed files with 147 additions and 60 deletions

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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(