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)
|
t := time.Unix(event.OccurredAt, 0).In(loc)
|
||||||
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, 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
|
// Reject if the new time collides with another event's timestamp
|
||||||
events, err := h.DB.EventsForDayByDate(user.ID, date)
|
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)
|
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.recalcDayWorkSeconds(user.ID, date, loc)
|
||||||
|
h.recalcAggregates(user.ID, loc)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
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)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.recalcDayWorkSeconds(user.ID, date, loc)
|
||||||
|
h.recalcAggregates(user.ID, loc)
|
||||||
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
|
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)
|
today := now.Format(DateLayout)
|
||||||
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
|
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)
|
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)
|
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
|
||||||
|
|
||||||
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
||||||
|
|||||||
@@ -47,14 +47,10 @@ func streakBonus(streak int) int64 {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeAndAwardXP calculates XP for a work session and updates the DB.
|
// computeAndAwardXP calculates XP for a work session and persists it.
|
||||||
// Returns the new total XP and whether the user leveled up.
|
// stats must already have the streak updated before calling.
|
||||||
func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) {
|
// Returns the new total XP, whether the user leveled up, and the new level.
|
||||||
stats, err := h.DB.GetOrCreateRPGStats(userID)
|
func (h *Handler) computeAndAwardXP(stats *db.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) {
|
||||||
if err != nil {
|
|
||||||
return 0, false, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
baseXP := xpForWorkSeconds(workSeconds)
|
baseXP := xpForWorkSeconds(workSeconds)
|
||||||
bonus := streakBonus(streak)
|
bonus := streakBonus(streak)
|
||||||
totalXP := baseXP + bonus
|
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.
|
// updateStreak advances or resets the user's streak based on today's activity.
|
||||||
// Returns the updated streak count.
|
// Only called from clockOut — incremental update, not a full scan.
|
||||||
func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (int, error) {
|
func (h *Handler) updateStreak(stats *db.RPGStats, userID int64, date string) {
|
||||||
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
|
|
||||||
t, err := time.Parse(DateLayout, date)
|
t, err := time.Parse(DateLayout, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return
|
||||||
}
|
}
|
||||||
yesterday := t.AddDate(0, 0, -1).Format(DateLayout)
|
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 {
|
if stats.CurrentStreak > stats.LongestStreak {
|
||||||
stats.LongestStreak = stats.CurrentStreak
|
stats.LongestStreak = stats.CurrentStreak
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.DB.UpdateRPGStats(stats); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats.CurrentStreak, nil
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// 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.
|
// buildRPGStatus returns a formatted string showing the user's RPG stats.
|
||||||
func (h *Handler) buildRPGStatus(userID int64, loc *time.Location) string {
|
func (h *Handler) buildRPGStatus(userID int64, loc *time.Location) string {
|
||||||
stats, err := h.DB.GetOrCreateRPGStats(userID)
|
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("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))
|
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))
|
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
|
||||||
|
|
||||||
// Show achievements count
|
// Show achievements count
|
||||||
|
|||||||
@@ -593,7 +593,8 @@ func (s *Store) UpdateRPGStats(st *RPGStats) error {
|
|||||||
|
|
||||||
// -- Daily XP --
|
// -- 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 {
|
func (s *Store) UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error {
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
|
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
|
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.
|
// GetDailyXP returns the daily XP record for a user/date.
|
||||||
func (s *Store) GetDailyXP(userID int64, date string) (*DailyXP, error) {
|
func (s *Store) GetDailyXP(userID int64, date string) (*DailyXP, error) {
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
@@ -890,6 +911,31 @@ func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]D
|
|||||||
return days, rows.Err()
|
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").
|
// 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 {
|
func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error {
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
|
|||||||
Reference in New Issue
Block a user