Files
worktimeBot/internal/db/store.go
db123 5cd811e057 refactor: restore stored aggregates with incremental update on edit
- Restore TotalWorkSeconds, CurrentStreak, LongestStreak writes in
  computeAndAwardXP and updateStreak (incremental on clock out)
- Add recalcDayWorkSeconds and recalcAggregates for event edit path
  (recompute day + total + streak from scratch when events change)
- Call recalcAggregates after editEventTimeSet, deleteEventConfirm,
  addEventDo in calendar.go
- Remove runtime computeStreaks and totalWorkSeconds (N+1 scan)
  from RPG display, burnout, and clockOut
- Add SetDailyWorkSeconds, SumDailyWorkSeconds store methods
- Clean up unused dead functions and dead code in rpg.go
2026-06-25 02:19:40 +03:30

1039 lines
29 KiB
Go

// Package db provides the SQLite-backed data store for users, work types, days, and events.
package db
import (
"database/sql"
"embed"
"fmt"
"log/slog"
"time"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)
const driverName = "sqlite"
//go:embed migrations/*.sql
var migrationFS embed.FS
// Store wraps a SQLite database connection and provides methods for all data access.
type Store struct {
db *sql.DB
}
// User represents a bot user with identity, timezone, and calendar preferences.
type User struct {
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
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
Name string
CreatedAt int64
}
// Day represents a single day entry for a user, including day-off status and work type.
type Day struct {
ID int64
UserID int64
Date string
IsDayOff bool
DayOffReason string
CurrentWorkTypeID int64
MinBreakThreshold int64
CreatedAt int64
UpdatedAt int64
}
// Event represents a single timestamped event ("in" or "out") for a user on a given day.
type Event struct {
ID int64
UserID int64
DayID int64
EventType string
WorkTypeID *int64
OccurredAt int64
Note string
CreatedAt int64
}
// NewStore opens or creates the SQLite database at path, runs migrations, and returns a Store.
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
}
// Close closes the underlying SQLite database connection.
func (s *Store) Close() error {
return s.db.Close()
}
// DB returns the underlying *sql.DB for advanced use.
func (s *Store) DB() *sql.DB {
return s.db
}
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 {
slog.Warn("hasTable query failed", "table", name, "error", err)
return false
}
return exists > 0
}
// runMigrations handles all database migrations using goose.
func runMigrations(db *sql.DB) error {
// Step 1: Handle the ancient single-table schema (pre-goose era).
// Rename old tables so goose can create the current schema cleanly.
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
}
}
}
}
// Step 2: Run goose (creates goose_db_version table if needed,
// applies pending migrations).
goose.SetBaseFS(migrationFS)
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
if err := goose.Up(db, "migrations"); err != nil {
return err
}
// Step 3: Copy old data into the freshly created tables.
if needsOldMigrate {
if err := migrateFromOldSchemaData(db); err != nil {
return err
}
}
return nil
}
func hasCol(db *sql.DB, table, column string) bool {
rows, err := db.Query("PRAGMA table_info(" + table + ")")
if err != nil {
slog.Warn("hasCol query failed", "table", table, "error", err)
return false
}
defer func() {
if err := rows.Close(); err != nil {
slog.Warn("hasCol rows.Close failed", "table", table, "error", err)
}
}()
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 {
slog.Warn("hasCol scan failed", "table", table, "error", err)
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 func() {
if err := rows.Close(); err != nil {
slog.Warn("migration: rows.Close failed in migrateUsers", "error", err)
}
}()
for rows.Next() {
var chatID int64
if err := rows.Scan(&chatID); err != nil {
slog.Warn("migration: scan failed in migrateUsers", "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 func() {
if err := rows.Close(); err != nil {
slog.Warn("migration: rows.Close failed in migrateDaysOff", "error", err)
}
}()
for rows.Next() {
var chatID int64
var date, reason string
if err := rows.Scan(&chatID, &date, &reason); err != nil {
slog.Warn("migration: scan failed in migrateDaysOff", "error", err)
continue
}
var userID int64
err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID)
if err != nil {
slog.Warn("migration: user not found for days_off", "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 func() {
if err := rows.Close(); err != nil {
slog.Warn("migration: rows.Close failed in migrateEvents", "error", err)
}
}()
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 in migrateEvents", "error", err)
continue
}
var userID int64
err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID)
if err != nil {
slog.Warn("migration: user not found for event", "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 after insert", "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 func() {
if err := rows.Close(); err != nil {
slog.Warn("migration: rows.Close failed in migrateSettings", "error", err)
}
}()
for rows.Next() {
var chatID int64
var key, value string
if err := rows.Scan(&chatID, &key, &value); err != nil {
slog.Warn("migration: scan failed in migrateSettings", "error", err)
continue
}
if key == "remote_flag" {
var userID int64
err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID)
if err != nil {
slog.Warn("migration: user not found for setting", "chat_id", chatID, "error", err)
continue
}
wt := int64(1)
if value == "remote" {
wt = 2
}
if _, err := db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02")); err != nil {
slog.Warn("migration: failed to update work type", "user_id", userID, "error", err)
}
}
}
}
// 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, username) VALUES (?, ?)",
chatID, randomUsername(),
)
if err != nil {
return nil, err
}
return s.GetUserByChatID(chatID)
}
// 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, username, timezone, is_active, calendar, created_at, updated_at
FROM users WHERE chat_id = ?
`, chatID)
var u User
err := row.Scan(
&u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&u.Calendar, &u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
return nil, err
}
return &u, nil
}
// UpdateUser persists core fields of the user record.
func (s *Store) UpdateUser(user *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
}
// GetAllUsers returns all users in the database.
func (s *Store) GetAllUsers() ([]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 []User
for rows.Next() {
var u 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()
}
// -- 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 and work seconds for a user/date.
// xpEarned and workSeconds are accumulated on conflict.
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
}
// SetDailyWorkSeconds overwrites the work_seconds for a specific day (used during recalc on edit).
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
}
// SumDailyWorkSeconds returns the sum of work_seconds across all days for the user.
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
}
// 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")
if err != nil {
return nil, err
}
defer rows.Close()
var wts []WorkType
for rows.Next() {
var wt WorkType
if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil {
return nil, err
}
wts = append(wts, wt)
}
return wts, rows.Err()
}
// GetWorkType returns a single work type by its ID.
func (s *Store) GetWorkType(id int64) (*WorkType, error) {
var wt 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
}
// GetOrCreateDay retrieves a day record for a user/date, creating one if it does not exist.
func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) {
_, err := s.db.Exec(
"INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)",
userID, date,
)
if err != nil {
return nil, err
}
return s.GetDay(userID, date)
}
// GetDay returns a single day record for the given user and date.
func (s *Store) GetDay(userID int64, date string) (*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 Day
err := row.Scan(
&d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason,
&d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt,
)
if err != nil {
return nil, err
}
return &d, nil
}
// UpdateDay persists the given day record's editable fields.
func (s *Store) UpdateDay(day *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
}
// SetDayOff marks a user's day as a day off with an optional reason.
func (s *Store) SetDayOff(userID int64, date string, reason string) error {
_, err := s.GetOrCreateDay(userID, date)
if 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
}
// RemoveDayOff clears the day-off flag for a user on the given date.
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
}
// IsDayOff checks whether the given date is a day off for the user.
func (s *Store) IsDayOff(userID int64, date string) (bool, error) {
var isOff bool
err := s.db.QueryRow(
"SELECT is_day_off FROM days WHERE user_id=? AND date=?",
userID, date,
).Scan(&isOff)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return isOff, nil
}
// GetUserDaysInRange returns all day records for a user within a date range (inclusive).
func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]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 []Day
for rows.Next() {
var d 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()
}
// GetDatesWithEvents returns all distinct dates (from the days table) on which
// the user has at least one event, ordered ascending.
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()
}
// CreateEvent inserts a new event record (typically "in" or "out").
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
}
// GetLastEvent returns the most recent event for the user, ordered by occurrence time.
func (s *Store) GetLastEvent(userID int64) (*Event, error) {
row, err := s.db.Query(`
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)
if err != nil {
return nil, err
}
defer func() {
if err := row.Close(); err != nil {
slog.Warn("GetLastEvent: row.Close failed", "error", err)
}
}()
if !row.Next() {
return nil, sql.ErrNoRows
}
var e 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
}
// EventsForDayByDate returns all events for a user on a specific date, ordered by occurrence.
func (s *Store) EventsForDayByDate(userID int64, date string) ([]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 []Event
for rows.Next() {
var e 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()
}
// SetEventNote updates the note for an event.
func (s *Store) SetEventNote(eventID int64, note string) error {
_, err := s.db.Exec("UPDATE events SET note=? WHERE id=?", note, eventID)
return err
}
// EventsForDayByDayID returns all events for a day record, ordered by occurrence.
func (s *Store) EventsForDayByDayID(dayID int64) ([]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 []Event
for rows.Next() {
var e 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()
}