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:
2026-06-25 02:25:13 +03:30
parent 5cd811e057
commit af23f80fbb
6 changed files with 207 additions and 72 deletions

View File

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

View File

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

View File

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

View File

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

View File

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