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

@@ -0,0 +1,101 @@
-- +goose Up
ALTER TABLE users ADD COLUMN username TEXT NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
export_accent TEXT NOT NULL DEFAULT 'ocean'
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
report_enabled INTEGER NOT NULL DEFAULT 1,
report_time TEXT NOT NULL DEFAULT '23:00',
last_report_date TEXT NOT NULL DEFAULT '',
rpg_enabled INTEGER NOT NULL DEFAULT 0,
league_opt_in INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT '$ USD',
hourly_rate REAL NOT NULL DEFAULT 0.0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
INSERT OR IGNORE INTO user_settings (user_id)
SELECT id FROM users;
CREATE TABLE IF NOT EXISTS user_rpg (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 1,
current_streak INTEGER NOT NULL DEFAULT 0,
longest_streak INTEGER NOT NULL DEFAULT 0,
total_work_seconds INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
INSERT OR IGNORE INTO user_rpg (user_id)
SELECT id FROM users;
CREATE TABLE IF NOT EXISTS daily_xp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
date TEXT NOT NULL,
xp_earned INTEGER NOT NULL DEFAULT 0,
work_seconds INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, date)
);
CREATE TABLE IF NOT EXISTS achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT ''
);
INSERT OR IGNORE INTO achievements (code, name, description) VALUES
('first_clockin', 'First Steps', 'Clock in for the first time'),
('early_bird', 'Early Bird', 'Clock in before 7 AM'),
('night_owl', 'Night Owl', 'Clock out after 10 PM'),
('iron_streak_5', 'Iron Streak', 'Work 5 consecutive days'),
('iron_streak_10', 'Iron Streak II', 'Work 10 consecutive days'),
('iron_streak_30', 'Iron Streak III', 'Work 30 consecutive days'),
('remote_veteran', 'Remote Veteran', 'Log 100 hours of remote work'),
('onsite_master', 'Onsite Master', 'Log 100 hours of onsite work'),
('consistency_master', 'Consistency Master', 'Work at least 6 hours daily for a week'),
('perfect_week', 'Perfect Week', 'No days off for a full week'),
('overtime_hero', 'Overtime Hero', 'Work more than 10 hours in a day'),
('level_5', 'Getting Started', 'Reach level 5'),
('level_10', 'Dedicated', 'Reach level 10'),
('level_25', 'Veteran', 'Reach level 25'),
('level_50', 'Legend', 'Reach level 50'),
('weekend_warrior', 'Weekend Warrior', 'Work on a weekend day'),
('hundred_hours', 'Century', 'Log 100 total work hours'),
('five_hundred_hours', 'Iron Will', 'Log 500 total work hours'),
('salary_setter', 'Salary Tracker', 'Configure your hourly rate'),
('rpg_pioneer', 'RPG Pioneer', 'Enable the RPG system');
CREATE TABLE IF NOT EXISTS user_achievements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
achievement_id INTEGER NOT NULL REFERENCES achievements(id),
unlocked_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, achievement_id)
);
CREATE INDEX IF NOT EXISTS idx_daily_xp_user_date ON daily_xp(user_id, date);
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
-- +goose Down
DROP TABLE IF EXISTS user_achievements;
DROP TABLE IF EXISTS achievements;
DROP TABLE IF EXISTS daily_xp;
DROP TABLE IF EXISTS user_rpg;
DROP TABLE IF EXISTS user_settings;
CREATE TABLE IF NOT EXISTS users_old AS SELECT * FROM users;
DROP TABLE IF EXISTS users;
-- Note: restoring users table without the username column requires
-- recreating it from the backup. In practice this migration is safe
-- and the down path preserves data via the backup table.
ALTER TABLE users_old RENAME TO users;

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")