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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user