refactor: break up large functions, add RPG/league/salary/burnout, fix bugs, add tests

- Refactor GenerateMonthlyReport (198→70), HandleCallback (128→85),
  handleHistoryCallback (131→38), handleExportCallback (100→25),
  checkAchievements (86→25) into extracted helper functions
- Add RPG system (XP, levels, streak, achievements, burnout)
- Add WorkTime League leaderboard
- Add salary estimation with configurable currency/rate
- Add input sanitization (SanitizeNote, SanitizeDisplayName)
- Add settings select-style pickers for toggles (report, RPG, league)
- Add break threshold inline picker UI
- Add event notes with pending state and /note command
- Add multi-calendar support (Jalali, Hijri)
- Add Excel export with theme picker
- Fix: getTodayBreakThreshold uses user's timezone (was UTC)
- Fix: acknowledgeCallback passes real callback ID
- Fix: currency symbol safety with currencySymbol() helper
- Fix: count work hours in summary on day-off days
- Fix: unsilence all error returns from SQL Exec/Bot API/migrations
- Remove stale db/schema.sql and unreferenced computeWeekTotals
- Extract constants: DateLayout, TimeLayout, secondsPerHour, etc.
- Add 12 new tests (XP, salary, sanitize, currency, dateutil)
- Remove duplicate package doc comment in totals.go
This commit is contained in:
2026-06-25 01:02:16 +03:30
parent 0d942c51db
commit 344c615666
18 changed files with 2510 additions and 483 deletions

View File

@@ -4,6 +4,7 @@ package db
import (
"database/sql"
"embed"
"fmt"
"log/slog"
"time"
@@ -21,21 +22,75 @@ type Store struct {
db *sql.DB
}
// User represents a bot user with timezone, reporting preferences, and calendar type.
// User represents a bot user with identity, timezone, and calendar preferences.
type User struct {
ID int64
ChatID int64
Timezone string
IsActive bool
ID int64
ChatID int64
Username string
Timezone string
IsActive bool
Calendar string
CreatedAt int64
UpdatedAt int64
}
// UserSettings holds all user-configurable preferences.
type UserSettings struct {
UserID int64
ExportAccent string
ReportEnabled bool
ReportTime string
LastReportDate *string
ExportAccent string
Calendar string
LastReportDate string
RPGEnabled bool
LeagueOptIn bool
Currency string
HourlyRate float64
CreatedAt int64
UpdatedAt int64
}
// RPGStats holds the user's RPG progression data.
type RPGStats struct {
UserID int64
XP int64
Level int
CurrentStreak int
LongestStreak int
TotalWorkSeconds int64
CreatedAt int64
UpdatedAt int64
}
// Achievement is a predefined achievement definition.
type Achievement struct {
ID int64
Code string
Name string
Description string
Icon string
}
// UserAchievement records when a user unlocked an achievement.
type UserAchievement struct {
ID int64
UserID int64
AchievementID int64
Code string
Name string
Description string
UnlockedAt int64
}
// DailyXP records XP earned per day per user.
type DailyXP struct {
ID int64
UserID int64
Date string
XPEarned int64
WorkSeconds int64
CreatedAt int64
}
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
type WorkType struct {
ID int64
@@ -345,11 +400,34 @@ func migrateSettings(db *sql.DB) {
}
}
// usernames is a pool of fun default usernames assigned randomly to new users.
var usernames = []string{
"Lazy Dev", "Solo Leveler", "Local = Prod", "Database Rat",
"Refactoring Perfectionist", "PHP 5 Cultist", "Java Script Slave",
"AI Slop", "OOP Cultist", "Functional Purist",
"Merge Conflict Survivor", "Stack Overflow Diplomat",
"NULL Pointer", "Memory Leak", "Segmentation Fault",
"Infinite Looper", "TypeScript Wizard", "CSS Box Model Gambler",
"Docker Compose Gambler", "YAML Engineer",
"Production Pusher", "Test Skipper", "Code Review Dodger",
"Commit --force User", "Git Merge Master",
"Dependency Hell Dweller", "Deprecation Warning",
"Legacy Code Whisperer", "Agile Scrum Lord", "Stand Up Sitter",
"Sprint Burnout", "Ticket Creator Supreme",
"Technical Debt Collector", "Documentation? Never Heard",
"Stack Trace Reader", "Binary Search Debugger", "Rubber Duck",
"Hotfix Hero", "Feature Flag Addict",
}
func randomUsername() string {
return usernames[time.Now().UnixNano()%int64(len(usernames))]
}
// GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists.
func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
_, err := s.db.Exec(
"INSERT OR IGNORE INTO users (chat_id) VALUES (?)",
chatID,
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
chatID, randomUsername(),
)
if err != nil {
return nil, err
@@ -360,39 +438,25 @@ func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
// GetUserByChatID retrieves a single user by their Telegram chat ID.
func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
row := s.db.QueryRow(`
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
last_report_date, export_accent, calendar, created_at, updated_at
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users WHERE chat_id = ?
`, chatID)
var u User
var lastReport sql.NullString
err := row.Scan(
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
return nil, err
}
if lastReport.Valid {
u.LastReportDate = &lastReport.String
}
return &u, nil
}
// UpdateUser persists all fields of the given user record.
// UpdateUser persists core fields of the user record.
func (s *Store) UpdateUser(user *User) error {
_, err := s.db.Exec(
"UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, calendar=?, updated_at=unixepoch() WHERE id=?",
user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID,
)
return err
}
// MarkReportSent records the last report date for a user to prevent duplicate daily reports.
func (s *Store) MarkReportSent(userID int64, date string) error {
_, err := s.db.Exec(
"UPDATE users SET last_report_date=?, updated_at=unixepoch() WHERE id=?",
date, userID,
"UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
user.Username, user.Timezone, user.IsActive, user.Calendar, user.ID,
)
return err
}
@@ -400,8 +464,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error {
// GetAllUsers returns all users in the database.
func (s *Store) GetAllUsers() ([]User, error) {
rows, err := s.db.Query(`
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
last_report_date, export_accent, calendar, created_at, updated_at
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users
`)
if err != nil {
@@ -412,21 +475,289 @@ func (s *Store) GetAllUsers() ([]User, error) {
var users []User
for rows.Next() {
var u User
var lastReport sql.NullString
if err := rows.Scan(
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime,
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, err
}
if lastReport.Valid {
u.LastReportDate = &lastReport.String
}
users = append(users, u)
}
return users, rows.Err()
}
// -- User Settings --
// GetOrCreateSettings retrieves settings for a user, creating defaults if none exist.
func (s *Store) GetOrCreateSettings(userID int64) (*UserSettings, error) {
_, err := s.db.Exec(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
userID,
)
if err != nil {
return nil, err
}
return s.GetSettings(userID)
}
// GetSettings retrieves the settings for a user.
func (s *Store) GetSettings(userID int64) (*UserSettings, error) {
row := s.db.QueryRow(`
SELECT user_id, export_accent, report_enabled, report_time, last_report_date,
rpg_enabled, league_opt_in, currency, hourly_rate, created_at, updated_at
FROM user_settings WHERE user_id = ?
`, userID)
var st UserSettings
err := row.Scan(
&st.UserID, &st.ExportAccent, &st.ReportEnabled, &st.ReportTime, &st.LastReportDate,
&st.RPGEnabled, &st.LeagueOptIn, &st.Currency, &st.HourlyRate, &st.CreatedAt, &st.UpdatedAt,
)
if err != nil {
return nil, err
}
return &st, nil
}
// UpdateSettings persists all fields of the user settings.
func (s *Store) UpdateSettings(st *UserSettings) error {
_, err := s.db.Exec(`
UPDATE user_settings SET export_accent=?, report_enabled=?, report_time=?,
last_report_date=?, rpg_enabled=?, league_opt_in=?, currency=?,
hourly_rate=?, updated_at=unixepoch()
WHERE user_id=?
`, st.ExportAccent, st.ReportEnabled, st.ReportTime, st.LastReportDate,
st.RPGEnabled, st.LeagueOptIn, st.Currency, st.HourlyRate, st.UserID)
return err
}
// MarkReportSent records the last report date for a user.
func (s *Store) MarkReportSent(userID int64, date string) error {
st, err := s.GetOrCreateSettings(userID)
if err != nil {
return err
}
st.LastReportDate = date
return s.UpdateSettings(st)
}
// GetReportEnabled returns whether daily reports are enabled for the user.
func (s *Store) GetReportEnabled(userID int64) bool {
st, err := s.GetOrCreateSettings(userID)
if err != nil {
return true
}
return st.ReportEnabled
}
// -- RPG Stats --
// GetOrCreateRPGStats retrieves RPG stats for a user, creating defaults if none exist.
func (s *Store) GetOrCreateRPGStats(userID int64) (*RPGStats, error) {
_, err := s.db.Exec(
"INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)",
userID,
)
if err != nil {
return nil, err
}
return s.GetRPGStats(userID)
}
// GetRPGStats retrieves the RPG stats for a user.
func (s *Store) GetRPGStats(userID int64) (*RPGStats, error) {
row := s.db.QueryRow(`
SELECT user_id, xp, level, current_streak, longest_streak, total_work_seconds,
created_at, updated_at
FROM user_rpg WHERE user_id = ?
`, userID)
var st RPGStats
err := row.Scan(
&st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak,
&st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt,
)
if err != nil {
return nil, err
}
return &st, nil
}
// UpdateRPGStats persists the RPG stats for a user.
func (s *Store) UpdateRPGStats(st *RPGStats) error {
_, err := s.db.Exec(`
UPDATE user_rpg SET xp=?, level=?, current_streak=?, longest_streak=?,
total_work_seconds=?, updated_at=unixepoch()
WHERE user_id=?
`, st.XP, st.Level, st.CurrentStreak, st.LongestStreak, st.TotalWorkSeconds, st.UserID)
return err
}
// -- Daily XP --
// UpsertDailyXP inserts or updates the daily XP record for a user/date.
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)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, date) DO UPDATE SET
xp_earned = xp_earned + ?,
work_seconds = work_seconds + ?
`, userID, date, xpEarned, workSeconds, xpEarned, workSeconds)
return 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(`
SELECT id, user_id, date, xp_earned, work_seconds, created_at
FROM daily_xp WHERE user_id=? AND date=?
`, userID, date)
var dx DailyXP
err := row.Scan(&dx.ID, &dx.UserID, &dx.Date, &dx.XPEarned, &dx.WorkSeconds, &dx.CreatedAt)
if err != nil {
return nil, err
}
return &dx, nil
}
// -- Achievements --
// GetAllAchievements returns all defined achievements.
func (s *Store) GetAllAchievements() ([]Achievement, error) {
rows, err := s.db.Query("SELECT id, code, name, description, icon FROM achievements ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
var achs []Achievement
for rows.Next() {
var a Achievement
if err := rows.Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon); err != nil {
return nil, err
}
achs = append(achs, a)
}
return achs, rows.Err()
}
// GetAchievementByCode returns an achievement by its code string.
func (s *Store) GetAchievementByCode(code string) (*Achievement, error) {
var a Achievement
err := s.db.QueryRow(
"SELECT id, code, name, description, icon FROM achievements WHERE code=?",
code,
).Scan(&a.ID, &a.Code, &a.Name, &a.Description, &a.Icon)
if err != nil {
return nil, err
}
return &a, nil
}
// GetUserAchievements returns all achievements unlocked by a user.
func (s *Store) GetUserAchievements(userID int64) ([]UserAchievement, error) {
rows, err := s.db.Query(`
SELECT ua.id, ua.user_id, ua.achievement_id, a.code, a.name, a.description, ua.unlocked_at
FROM user_achievements ua
JOIN achievements a ON a.id = ua.achievement_id
WHERE ua.user_id = ?
ORDER BY ua.unlocked_at ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var uas []UserAchievement
for rows.Next() {
var ua UserAchievement
if err := rows.Scan(&ua.ID, &ua.UserID, &ua.AchievementID, &ua.Code, &ua.Name, &ua.Description, &ua.UnlockedAt); err != nil {
return nil, err
}
uas = append(uas, ua)
}
return uas, rows.Err()
}
// UnlockAchievement grants an achievement to a user (no-op if already owned).
func (s *Store) UnlockAchievement(userID int64, achievementID int64) (bool, error) {
res, err := s.db.Exec(
"INSERT OR IGNORE INTO user_achievements (user_id, achievement_id) VALUES (?, ?)",
userID, achievementID,
)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// HasAchievement checks whether a user has unlocked a specific achievement code.
func (s *Store) HasAchievement(userID int64, code string) (bool, error) {
var count int
err := s.db.QueryRow(`
SELECT COUNT(1) FROM user_achievements ua
JOIN achievements a ON a.id = ua.achievement_id
WHERE ua.user_id=? AND a.code=?
`, userID, code).Scan(&count)
return count > 0, err
}
// -- League / Rankings --
// LeagueEntry represents a user in a league ranking.
type LeagueEntry struct {
UserID int64
Username string
XP int64
Level int
Streak int
TotalHours float64
}
// GetLeagueRankings returns all users who have opted into the league, ordered by the given column.
func (s *Store) GetLeagueRankings(orderBy string) ([]LeagueEntry, error) {
validOrders := map[string]bool{
"xp": true, "level": true, "streak": true, "hours": true,
}
if !validOrders[orderBy] {
orderBy = "xp"
}
col := "r.xp"
switch orderBy {
case "level":
col = "r.level"
case "streak":
col = "r.current_streak"
case "hours":
col = "r.total_work_seconds"
}
query := fmt.Sprintf(`
SELECT u.id, u.username, r.xp, r.level, r.current_streak, r.total_work_seconds
FROM user_rpg r
JOIN users u ON u.id = r.user_id
JOIN user_settings s ON s.user_id = r.user_id
WHERE s.league_opt_in = 1
ORDER BY %s DESC, u.username ASC
`, col)
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []LeagueEntry
for rows.Next() {
var e LeagueEntry
if err := rows.Scan(&e.UserID, &e.Username, &e.XP, &e.Level, &e.Streak, &e.TotalHours); err != nil {
return nil, err
}
e.TotalHours = float64(e.TotalHours) / 3600.0
entries = append(entries, e)
}
return entries, rows.Err()
}
// GetWorkTypes returns all work types ordered by ID.
func (s *Store) GetWorkTypes() ([]WorkType, error) {
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")