fix: address audit findings — nil pointers, timezone bug, dead code, tests, README
- Fix nil pointer dereference in clock.go (guard GetOrCreateSettings and GetOrCreateRPGStats errors before accessing fields) - Fix timezone bug in handleSetBreakMsg (was using system time.Now() instead of user's configured timezone) - Remove dead code: sendExportMonthPicker (unused wrapper) - Simplify xpForWorkSeconds (xpPerSecond=1, inline to just return sec) - Extract computeStreakFromDates as pure testable function from recalcAggregates; add 6 tests covering empty, current, gaps, no-today, long streak, longest-not-current edge cases - Update README with full feature documentation: RPG, League, Salary, Burnout, Work Types, Inline History Editing, all missing commands
This commit is contained in:
@@ -64,24 +64,26 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
|
||||
text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout))
|
||||
|
||||
// Award XP and update streaks if RPG enabled
|
||||
st, _ := h.DB.GetOrCreateSettings(user.ID)
|
||||
if st.RPGEnabled {
|
||||
st, err := h.DB.GetOrCreateSettings(user.ID)
|
||||
if err == nil && st.RPGEnabled {
|
||||
sessionWork := now.Unix() - last.OccurredAt
|
||||
today := now.Format(DateLayout)
|
||||
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
|
||||
|
||||
stats, _ := h.DB.GetOrCreateRPGStats(user.ID)
|
||||
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)
|
||||
stats, err := h.DB.GetOrCreateRPGStats(user.ID)
|
||||
if err == nil {
|
||||
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)
|
||||
if leveledUp {
|
||||
text += "\nLevel up!"
|
||||
}
|
||||
for _, a := range unlocked {
|
||||
text += fmt.Sprintf("\nAchievement: %s", a)
|
||||
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
||||
if leveledUp {
|
||||
text += "\nLevel up!"
|
||||
}
|
||||
for _, a := range unlocked {
|
||||
text += fmt.Sprintf("\nAchievement: %s", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -388,11 +388,6 @@ func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, c
|
||||
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
|
||||
}
|
||||
|
||||
// sendExportMonthPicker sends the export month picker as a new message.
|
||||
func (h *Handler) sendExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, month int, user *db.User) {
|
||||
h.editExportMonthPicker(ctx, chatID, msgID, year, month, user)
|
||||
}
|
||||
|
||||
// editExportMonthPicker renders a year-based month picker with 12 month buttons.
|
||||
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *db.User) {
|
||||
months := gregMonthNames
|
||||
|
||||
@@ -12,9 +12,6 @@ import (
|
||||
"worktimeBot/internal/db"
|
||||
)
|
||||
|
||||
// XP earned per second of work.
|
||||
const xpPerSecond int64 = 1
|
||||
|
||||
// xpForLevel returns the total XP required to reach level n.
|
||||
// Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier
|
||||
// Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP
|
||||
@@ -34,7 +31,7 @@ func levelForXP(xp int64) int {
|
||||
// xpForWorkSeconds calculates XP earned for a given amount of work.
|
||||
// Base: 1 XP per second.
|
||||
func xpForWorkSeconds(sec int64) int64 {
|
||||
return sec / xpPerSecond
|
||||
return sec
|
||||
}
|
||||
|
||||
// streakBonus returns bonus XP for maintaining a streak.
|
||||
@@ -47,6 +44,54 @@ func streakBonus(streak int) int64 {
|
||||
return b
|
||||
}
|
||||
|
||||
// computeStreakFromDates computes current and longest streak from a sorted list of date strings.
|
||||
// today is the reference date for the current streak.
|
||||
func computeStreakFromDates(dates []string, today string) (current, longest int) {
|
||||
if len(dates) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
dateSet := make(map[string]struct{}, len(dates))
|
||||
for _, d := range dates {
|
||||
dateSet[d] = struct{}{}
|
||||
}
|
||||
|
||||
// Current streak: count consecutive days backwards from today
|
||||
if _, ok := dateSet[today]; ok {
|
||||
current = 1
|
||||
for i := 1; ; i++ {
|
||||
t, _ := time.Parse(DateLayout, today)
|
||||
prev := t.AddDate(0, 0, -i).Format(DateLayout)
|
||||
if _, ok := dateSet[prev]; ok {
|
||||
current++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Longest streak: find longest consecutive run in sorted dates
|
||||
longest = 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 > longest {
|
||||
longest = run
|
||||
}
|
||||
run = 1
|
||||
}
|
||||
}
|
||||
if run > longest {
|
||||
longest = run
|
||||
}
|
||||
|
||||
return current, longest
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -135,45 +180,9 @@ func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
|
||||
|
||||
// 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
|
||||
}
|
||||
today := time.Now().In(loc).Format(DateLayout)
|
||||
if err == nil {
|
||||
stats.CurrentStreak, stats.LongestStreak = computeStreakFromDates(dates, today)
|
||||
} else {
|
||||
stats.CurrentStreak = 0
|
||||
stats.LongestStreak = 0
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -225,3 +226,72 @@ func TestComputeTrendFromAvgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStreakFromDates_Empty(t *testing.T) {
|
||||
cur, longest := computeStreakFromDates(nil, "2026-06-25")
|
||||
if cur != 0 || longest != 0 {
|
||||
t.Errorf("empty dates: got (%d, %d), want (0, 0)", cur, longest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStreakFromDates_CurrentOnly(t *testing.T) {
|
||||
dates := []string{"2026-06-25", "2026-06-26"}
|
||||
cur, longest := computeStreakFromDates(dates, "2026-06-26")
|
||||
if cur != 2 {
|
||||
t.Errorf("current streak = %d, want 2", cur)
|
||||
}
|
||||
if longest != 2 {
|
||||
t.Errorf("longest streak = %d, want 2", longest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStreakFromDates_GapBreaksStreak(t *testing.T) {
|
||||
dates := []string{"2026-06-23", "2026-06-25", "2026-06-26"}
|
||||
cur, longest := computeStreakFromDates(dates, "2026-06-26")
|
||||
if cur != 2 {
|
||||
t.Errorf("current streak = %d, want 2 (today and yesterday)", cur)
|
||||
}
|
||||
if longest != 2 {
|
||||
t.Errorf("longest streak = %d, want 2", longest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStreakFromDates_NoEventsToday(t *testing.T) {
|
||||
dates := []string{"2026-06-24", "2026-06-25"}
|
||||
cur, longest := computeStreakFromDates(dates, "2026-06-26")
|
||||
if cur != 0 {
|
||||
t.Errorf("current streak = %d, want 0 (no events today)", cur)
|
||||
}
|
||||
if longest != 2 {
|
||||
t.Errorf("longest streak = %d, want 2", longest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStreakFromDates_LongStreak(t *testing.T) {
|
||||
dates := []string{}
|
||||
for d := 1; d <= 10; d++ {
|
||||
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
|
||||
}
|
||||
cur, longest := computeStreakFromDates(dates, "2026-06-10")
|
||||
if cur != 10 {
|
||||
t.Errorf("current streak = %d, want 10", cur)
|
||||
}
|
||||
if longest != 10 {
|
||||
t.Errorf("longest streak = %d, want 10", longest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeStreakFromDates_LongestNotCurrent(t *testing.T) {
|
||||
dates := []string{}
|
||||
for d := 1; d <= 5; d++ {
|
||||
dates = append(dates, fmt.Sprintf("2026-06-%02d", d))
|
||||
}
|
||||
dates = append(dates, "2026-06-09", "2026-06-10")
|
||||
cur, longest := computeStreakFromDates(dates, "2026-06-10")
|
||||
if cur != 2 {
|
||||
t.Errorf("current streak = %d, want 2", cur)
|
||||
}
|
||||
if longest != 5 {
|
||||
t.Errorf("longest streak = %d, want 5", longest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,8 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
|
||||
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
date := now.Format(DateLayout)
|
||||
day, err := h.DB.GetOrCreateDay(user.ID, date)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user