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:
101
internal/db/migrations/002_features.sql
Normal file
101
internal/db/migrations/002_features.sql
Normal 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;
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user