fix: resolve go vet error and dayStart logic in ComputeDailyTotals

- Fix 'string(d)' int-to-rune conversion in rpg_test.go by using fmt.Sprintf
- Fix ComputeDailyTotals dayStart logic: initial state should be StateWorking
  when dayStart > 0, correctly counting work from dayStart to first out event
- Update README project structure to reflect new domain/handler/repo layout
- Implement missing achievements: rpg_pioneer (on RPG enable) and
  salary_setter (on first rate set)
- Add tryUnlockAchievement helper to Handler
This commit is contained in:
2026-06-25 09:54:22 +03:30
parent 3835b002a7
commit d15ed46066
45 changed files with 4947 additions and 5430 deletions

View File

@@ -0,0 +1,59 @@
package repo
import "worktimeBot/internal/domain"
type Repository interface {
// Users
GetOrCreateUser(chatID int64) (*domain.User, error)
UpdateUser(user *domain.User) error
GetAllUsers() ([]domain.User, error)
// Settings
GetOrCreateSettings(userID int64) (*domain.UserSettings, error)
UpdateSettings(st *domain.UserSettings) error
MarkReportSent(userID int64, date string) error
// Days
GetOrCreateDay(userID int64, date string) (*domain.Day, error)
GetDay(userID int64, date string) (*domain.Day, error)
UpdateDay(day *domain.Day) error
GetUserDaysInRange(userID int64, start, end string) ([]domain.Day, error)
SetDayOff(userID int64, date, reason string) error
RemoveDayOff(userID int64, date string) error
// Events
CreateEvent(userID, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error
GetLastEvent(userID int64) (*domain.Event, error)
EventsForDayByDate(userID int64, date string) ([]domain.Event, error)
EventsForDayByDayID(dayID int64) ([]domain.Event, error)
GetEventByID(eventID, userID int64) (*domain.Event, error)
UpdateEventTime(eventID int64, occurredAt int64) error
UpdateEventWorkType(eventID, workTypeID int64) error
SetEventNote(eventID int64, note string) error
DeleteEvent(eventID int64) error
GetDistinctEventDates(userID int64, start, end string) ([]string, error)
GetDatesWithEvents(userID int64) ([]string, error)
// Work Types
GetWorkTypes() ([]domain.WorkType, error)
GetWorkType(id int64) (*domain.WorkType, error)
// RPG
GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error)
UpdateRPGStats(stats *domain.RPGStats) error
UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error
SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error
SumDailyWorkSeconds(userID int64) (int64, error)
// Achievements
GetAchievementByCode(code string) (*domain.Achievement, error)
UnlockAchievement(userID, achievementID int64) (bool, error)
HasAchievement(userID int64, code string) (bool, error)
GetUserAchievements(userID int64) ([]domain.UserAchievement, error)
// League
GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error)
// Lifecycle
Close() error
}

221
internal/repo/migrations.go Normal file
View File

@@ -0,0 +1,221 @@
package repo
import (
"database/sql"
"embed"
"log/slog"
"time"
"github.com/pressly/goose/v3"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// runMigrations handles all database migrations using goose.
func runMigrations(db *sql.DB) error {
needsOldMigrate := hasTable(db, "events_old") || (hasTable(db, "events") && !hasCol(db, "events", "user_id"))
if needsOldMigrate {
slog.Info("migrating from old schema to new schema")
for _, t := range []string{"events", "days_off", "settings"} {
if hasTable(db, t) {
if _, err := db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old"); err != nil {
return err
}
}
}
}
goose.SetBaseFS(migrationFS)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
if err := goose.Up(db, "migrations"); err != nil {
return err
}
if needsOldMigrate {
if err := migrateFromOldSchemaData(db); err != nil {
return err
}
}
return nil
}
func hasTable(db *sql.DB, name string) bool {
var exists int
if err := db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists); err != nil {
return false
}
return exists > 0
}
func hasCol(db *sql.DB, table, column string) bool {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false
}
if name == column {
return true
}
}
return false
}
func migrateFromOldSchemaData(db *sql.DB) error {
migrateUsers(db)
migrateDaysOff(db)
migrateEvents(db)
migrateSettings(db)
for _, t := range []string{"days_off_old", "settings_old", "events_old"} {
if hasTable(db, t) {
if _, err := db.Exec("DROP TABLE IF EXISTS " + t); err != nil {
slog.Warn("failed to drop old table", "table", t, "error", err)
}
}
}
slog.Info("migration complete")
return nil
}
func migrateUsers(db *sql.DB) {
if !hasTable(db, "events_old") {
return
}
rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old")
if err != nil {
slog.Warn("migration: failed to query events_old for users", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
if err := rows.Scan(&chatID); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
if _, err := db.Exec(
"INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')",
chatID,
); err != nil {
slog.Warn("migration: failed to insert user", "chat_id", chatID, "error", err)
}
}
}
func migrateDaysOff(db *sql.DB) {
if !hasTable(db, "days_off_old") {
return
}
rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old")
if err != nil {
slog.Warn("migration: failed to query days_off_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var date, reason string
if err := rows.Scan(&chatID, &date, &reason); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
if _, err := db.Exec(
"INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason",
userID, date, reason,
); err != nil {
slog.Warn("migration: failed to insert day_off", "user_id", userID, "date", date, "error", err)
}
}
}
func migrateEvents(db *sql.DB) {
if !hasTable(db, "events_old") {
return
}
rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC")
if err != nil {
slog.Warn("migration: failed to query events_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var eventType, note string
var occurredAt, createdAt int64
if err := rows.Scan(&chatID, &eventType, &occurredAt, &note, &createdAt); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02")
if _, err := db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date); err != nil {
slog.Warn("migration: failed to insert day", "user_id", userID, "date", date, "error", err)
continue
}
var dayID int64
if err := db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID); err != nil {
slog.Warn("migration: day not found", "user_id", userID, "date", date, "error", err)
continue
}
if _, err := db.Exec(
"INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)",
userID, dayID, eventType, occurredAt, note, createdAt,
); err != nil {
slog.Warn("migration: failed to insert event", "user_id", userID, "error", err)
}
}
}
func migrateSettings(db *sql.DB) {
if !hasTable(db, "settings_old") {
return
}
rows, err := db.Query("SELECT chat_id, key, value FROM settings_old")
if err != nil {
slog.Warn("migration: failed to query settings_old", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var chatID int64
var key, value string
if err := rows.Scan(&chatID, &key, &value); err != nil {
slog.Warn("migration: scan failed", "error", err)
continue
}
if key == "remote_flag" {
var userID int64
if err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID); err != nil {
slog.Warn("migration: user not found", "chat_id", chatID, "error", err)
continue
}
wt := int64(1)
if value == "remote" {
wt = 2
}
db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02"))
}
}
}

View File

@@ -0,0 +1,59 @@
-- +goose Up
CREATE TABLE IF NOT EXISTS work_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
INSERT OR IGNORE INTO work_types (id, name) VALUES (1, 'onsite');
INSERT OR IGNORE INTO work_types (id, name) VALUES (2, 'remote');
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL UNIQUE,
timezone TEXT NOT NULL DEFAULT 'Asia/Tehran',
is_active INTEGER NOT NULL DEFAULT 1,
report_enabled INTEGER NOT NULL DEFAULT 1,
report_time TEXT NOT NULL DEFAULT '23:00',
last_report_date TEXT DEFAULT NULL,
export_accent TEXT NOT NULL DEFAULT 'ocean'
CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')),
calendar TEXT NOT NULL DEFAULT 'gregorian'
CHECK (calendar IN ('gregorian', 'jalali', 'hijri')),
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)),
is_day_off INTEGER NOT NULL DEFAULT 0,
day_off_reason TEXT NOT NULL DEFAULT '',
current_work_type_id INTEGER NOT NULL DEFAULT 1 REFERENCES work_types(id),
min_break_threshold INTEGER NOT NULL DEFAULT 300,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(user_id, date)
);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
day_id INTEGER NOT NULL REFERENCES days(id),
event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')),
work_type_id INTEGER REFERENCES work_types(id),
occurred_at INTEGER NOT NULL CHECK (occurred_at > 0),
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id);
CREATE INDEX IF NOT EXISTS idx_events_user_occurred ON events(user_id, occurred_at);
CREATE INDEX IF NOT EXISTS idx_days_user_date ON days(user_id, date);
-- +goose Down
DROP TABLE IF EXISTS events;
DROP TABLE IF EXISTS days;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS work_types;

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;

607
internal/repo/store.go Normal file
View File

@@ -0,0 +1,607 @@
package repo
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"time"
_ "modernc.org/sqlite"
"worktimeBot/internal/domain"
)
const driverName = "sqlite"
type Store struct {
db *sql.DB
}
func NewStore(path string) (*Store, error) {
db, err := sql.Open(driverName, path)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
slog.Warn("failed to set WAL mode", "error", err)
}
if err := runMigrations(db); err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
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))]
}
func (s *Store) GetOrCreateUser(chatID int64) (*domain.User, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
chatID, randomUsername(),
); err != nil {
return nil, err
}
return s.GetUserByChatID(chatID)
}
func (s *Store) GetUserByChatID(chatID int64) (*domain.User, error) {
row := s.db.QueryRow(`
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users WHERE chat_id = ?
`, chatID)
var u domain.User
if err := row.Scan(
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) UpdateUser(user *domain.User) error {
_, err := s.db.Exec(
"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
}
func (s *Store) GetAllUsers() ([]domain.User, error) {
rows, err := s.db.Query(`
SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
FROM users
`)
if err != nil {
return nil, err
}
defer rows.Close()
var users []domain.User
for rows.Next() {
var u domain.User
if err := rows.Scan(
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// Settings
func (s *Store) GetOrCreateSettings(userID int64) (*domain.UserSettings, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
userID,
); err != nil {
return nil, err
}
return s.getSettings(userID)
}
func (s *Store) getSettings(userID int64) (*domain.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 domain.UserSettings
if 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,
); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) UpdateSettings(st *domain.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
}
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)
}
// RPG Stats
func (s *Store) GetOrCreateRPGStats(userID int64) (*domain.RPGStats, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO user_rpg (user_id) VALUES (?)",
userID,
); err != nil {
return nil, err
}
return s.getRPGStats(userID)
}
func (s *Store) getRPGStats(userID int64) (*domain.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 domain.RPGStats
if err := row.Scan(
&st.UserID, &st.XP, &st.Level, &st.CurrentStreak, &st.LongestStreak,
&st.TotalWorkSeconds, &st.CreatedAt, &st.UpdatedAt,
); err != nil {
return nil, err
}
return &st, nil
}
func (s *Store) UpdateRPGStats(st *domain.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
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
}
func (s *Store) SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error {
_, err := s.db.Exec(`
INSERT INTO daily_xp (user_id, date, xp_earned, work_seconds)
VALUES (?, ?, 0, ?)
ON CONFLICT(user_id, date) DO UPDATE SET
work_seconds = ?
`, userID, date, workSeconds, workSeconds)
return err
}
func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) {
var total int64
err := s.db.QueryRow(
"SELECT COALESCE(SUM(work_seconds), 0) FROM daily_xp WHERE user_id=?", userID,
).Scan(&total)
return total, err
}
// Achievements
func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {
var a domain.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
}
func (s *Store) GetUserAchievements(userID int64) ([]domain.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 []domain.UserAchievement
for rows.Next() {
var ua domain.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()
}
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
}
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
func (s *Store) GetLeagueRankings(orderBy string) ([]domain.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 []domain.LeagueEntry
for rows.Next() {
var e domain.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()
}
// Work Types
func (s *Store) GetWorkTypes() ([]domain.WorkType, error) {
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
if err != nil {
return nil, err
}
defer rows.Close()
var wts []domain.WorkType
for rows.Next() {
var wt domain.WorkType
if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil {
return nil, err
}
wts = append(wts, wt)
}
return wts, rows.Err()
}
func (s *Store) GetWorkType(id int64) (*domain.WorkType, error) {
var wt domain.WorkType
err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt)
if err != nil {
return nil, err
}
return &wt, nil
}
// Days
func (s *Store) GetOrCreateDay(userID int64, date string) (*domain.Day, error) {
if _, err := s.db.Exec(
"INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)",
userID, date,
); err != nil {
return nil, err
}
return s.GetDay(userID, date)
}
func (s *Store) GetDay(userID int64, date string) (*domain.Day, error) {
row := s.db.QueryRow(`
SELECT id, user_id, date, is_day_off, day_off_reason,
current_work_type_id, min_break_threshold, created_at, updated_at
FROM days WHERE user_id=? AND date=?
`, userID, date)
var d domain.Day
if err := row.Scan(
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
return &d, nil
}
func (s *Store) UpdateDay(day *domain.Day) error {
_, err := s.db.Exec(
"UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?",
day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID,
)
return err
}
func (s *Store) SetDayOff(userID int64, date string, reason string) error {
if _, err := s.GetOrCreateDay(userID, date); err != nil {
return err
}
_, err := s.db.Exec(
"UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?",
reason, userID, date,
)
return err
}
func (s *Store) RemoveDayOff(userID int64, date string) error {
_, err := s.db.Exec(
"UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?",
userID, date,
)
return err
}
func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]domain.Day, error) {
rows, err := s.db.Query(`
SELECT id, user_id, date, is_day_off, day_off_reason,
current_work_type_id, min_break_threshold, created_at, updated_at
FROM days
WHERE user_id=? AND date >= ? AND date <= ?
ORDER BY date ASC
`, userID, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var days []domain.Day
for rows.Next() {
var d domain.Day
if err := rows.Scan(
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
days = append(days, d)
}
return days, rows.Err()
}
func (s *Store) GetDatesWithEvents(userID int64) ([]string, error) {
rows, err := s.db.Query(`
SELECT DISTINCT d.date
FROM days d
JOIN events e ON e.day_id = d.id
WHERE d.user_id = ?
ORDER BY d.date ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err != nil {
return nil, err
}
dates = append(dates, date)
}
return dates, rows.Err()
}
func (s *Store) GetDistinctEventDates(userID int64, startDate, endDate string) ([]string, error) {
rows, err := s.db.Query(
"SELECT DISTINCT d.date FROM days d JOIN events e ON e.day_id = d.id WHERE d.user_id=? AND d.date >= ? AND d.date <= ? ORDER BY d.date",
userID, startDate, endDate,
)
if err != nil {
return nil, err
}
defer rows.Close()
var dates []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
return nil, err
}
dates = append(dates, d)
}
return dates, rows.Err()
}
// Events
func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error {
_, err := s.db.Exec(
"INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)",
userID, dayID, eventType, workTypeID, occurredAt, note,
)
return err
}
func (s *Store) UpdateEventWorkType(eventID, workTypeID int64) error {
_, err := s.db.Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID)
return err
}
func (s *Store) UpdateEventTime(eventID, occurredAt int64) error {
_, err := s.db.Exec("UPDATE events SET occurred_at=? WHERE id=?", occurredAt, eventID)
return err
}
func (s *Store) DeleteEvent(eventID int64) error {
_, err := s.db.Exec("DELETE FROM events WHERE id=?", eventID)
return err
}
func (s *Store) GetEventByID(eventID, userID int64) (*domain.Event, error) {
row := s.db.QueryRow(
"SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?",
eventID, userID,
)
var e domain.Event
var wt sql.NullInt64
if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
return &e, nil
}
func (s *Store) GetLastEvent(userID int64) (*domain.Event, error) {
row := s.db.QueryRow(`
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
FROM events
WHERE user_id = ?
ORDER BY occurred_at DESC
LIMIT 1
`, userID)
var e domain.Event
var wt sql.NullInt64
if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, sql.ErrNoRows
}
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
return &e, nil
}
func (s *Store) EventsForDayByDate(userID int64, date string) ([]domain.Event, error) {
rows, err := s.db.Query(`
SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at
FROM events e
JOIN days d ON d.id = e.day_id
WHERE d.user_id = ? AND d.date = ?
ORDER BY e.occurred_at ASC
`, userID, date)
if err != nil {
return nil, err
}
defer rows.Close()
var events []domain.Event
for rows.Next() {
var e domain.Event
var wt sql.NullInt64
if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
events = append(events, e)
}
return events, rows.Err()
}
func (s *Store) EventsForDayByDayID(dayID int64) ([]domain.Event, error) {
rows, err := s.db.Query(`
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
FROM events
WHERE day_id = ?
ORDER BY occurred_at ASC
`, dayID)
if err != nil {
return nil, err
}
defer rows.Close()
var events []domain.Event
for rows.Next() {
var e domain.Event
var wt sql.NullInt64
if err := rows.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil {
return nil, err
}
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
events = append(events, e)
}
return events, rows.Err()
}
func (s *Store) SetEventNote(eventID int64, note string) error {
_, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID)
return err
}