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

@@ -101,7 +101,12 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) {
now := time.Now().UTC() now := time.Now().UTC()
for _, u := range users { for _, u := range users {
if !u.ReportEnabled || !u.IsActive { if !u.IsActive {
continue
}
st, err := store.GetOrCreateSettings(u.ID)
if err != nil || !st.ReportEnabled {
continue continue
} }
@@ -114,11 +119,11 @@ func checkDailyReports(ctx context.Context, h *bot.Handler, store *db.Store) {
today := localNow.Format("2006-01-02") today := localNow.Format("2006-01-02")
currentHHMM := localNow.Format("15:04") currentHHMM := localNow.Format("15:04")
if currentHHMM != u.ReportTime { if currentHHMM != st.ReportTime {
continue continue
} }
if u.LastReportDate != nil && *u.LastReportDate == today { if st.LastReportDate == today {
continue continue
} }

View File

@@ -1,52 +0,0 @@
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);

299
internal/bot/burnout.go Normal file
View File

@@ -0,0 +1,299 @@
package bot
import (
"fmt"
"math"
"strings"
"time"
"worktimeBot/internal/db"
)
// Burnout scoring model
// ======================
// Total score out of 100, computed as a weighted sum of signals:
//
// 1. Consecutive workdays (0-25 pts)
// Measures how many days the user has worked without a break.
// 0 days: 0, 1-3: 5, 4-6: 15, 7+: 25
//
// 2. Long hours today (0-20 pts)
// Measures daily work duration.
// <8h: 0, 8-10h: 10, 10-12h: 15, 12h+: 20
//
// 3. Reduced breaks (0-15 pts)
// Measures break ratio (break / (work+break)).
// >20%: 0, 10-20%: 5, 5-10%: 10, <5%: 15
//
// 4. Weekend work (0-15 pts)
// Working on Saturday or Sunday in user's timezone.
// Not weekend: 0, Weekend: 15
//
// 5. Work trend (0-15 pts)
// Compares last 3 days to the 3 before that.
// Decreasing: 0, Stable: 5, Increasing: 15
//
// 6. Late-night work (0-10 pts)
// Last event after typical working hours.
// Before 20:00: 0, 20:00-22:00: 5, After 22:00: 10
//
// Total -> Concern level:
// 0-20: Healthy
// 21-40: Mild concern
// 41-60: Moderate concern
// 61+: High concern
// BurnoutLevel represents the severity of burnout indicators.
type BurnoutLevel int
const (
BurnoutHealthy BurnoutLevel = 0
BurnoutMild BurnoutLevel = 1
BurnoutModerate BurnoutLevel = 2
BurnoutHigh BurnoutLevel = 3
)
func (b BurnoutLevel) String() string {
switch b {
case BurnoutHealthy:
return "Healthy"
case BurnoutMild:
return "Mild concern"
case BurnoutModerate:
return "Moderate concern"
case BurnoutHigh:
return "High concern"
default:
return "Unknown"
}
}
// BurnoutResult holds the full burnout assessment.
type BurnoutResult struct {
Score int
Level BurnoutLevel
ConsecutivePts int
LongHoursPts int
BreakRatioPts int
WeekendPts int
TrendPts int
LateNightPts int
ConsecutiveDays int
WorkSeconds int64
BreakSeconds int64
TrendDirection string
}
// assessBurnout computes a burnout assessment for a user.
func (h *Handler) assessBurnout(userID int64, loc *time.Location) (*BurnoutResult, error) {
now := time.Now().In(loc)
today := now.Format(DateLayout)
result := &BurnoutResult{}
// 1. Consecutive workdays
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err == nil {
result.ConsecutiveDays = stats.CurrentStreak
}
switch {
case result.ConsecutiveDays >= 7:
result.ConsecutivePts = 25
case result.ConsecutiveDays >= 4:
result.ConsecutivePts = 15
case result.ConsecutiveDays >= 1:
result.ConsecutivePts = 5
}
// 2. Long hours today
todayEvents, _ := h.DB.EventsForDayByDate(userID, today)
if len(todayEvents) > 0 {
day, _ := h.DB.GetDay(userID, today)
if day != nil {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
dayEnd := now.Unix()
totals := ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, dayEnd)
result.WorkSeconds = totals.TotalSeconds
result.BreakSeconds = totals.BreakSeconds
workHours := float64(totals.TotalSeconds) / secondsPerHour
switch {
case workHours >= 12:
result.LongHoursPts = 20
case workHours >= 10:
result.LongHoursPts = 15
case workHours >= 8:
result.LongHoursPts = 10
}
// 3. Reduced breaks
total := totals.TotalSeconds + totals.BreakSeconds
if total > 0 {
breakRatio := float64(totals.BreakSeconds) / float64(total) * 100
switch {
case breakRatio < 5:
result.BreakRatioPts = 15
case breakRatio < 10:
result.BreakRatioPts = 10
case breakRatio < 20:
result.BreakRatioPts = 5
}
}
// 6. Late-night work
if len(todayEvents) > 0 {
lastEvent := todayEvents[len(todayEvents)-1]
lt := time.Unix(lastEvent.OccurredAt, 0).In(loc)
minutes := lt.Hour()*60 + lt.Minute()
switch {
case minutes >= 22*60:
result.LateNightPts = 10
case minutes >= 20*60:
result.LateNightPts = 5
}
}
}
}
// 4. Weekend work
weekday := now.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
if result.WorkSeconds > 0 {
result.WeekendPts = 15
}
}
// 5. Work trend (last 3 days vs 3 before that)
result.TrendPts, result.TrendDirection = computeTrend(h.DB, userID, today, loc)
// Total
result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts +
result.WeekendPts + result.TrendPts + result.LateNightPts
switch {
case result.Score >= 61:
result.Level = BurnoutHigh
case result.Score >= 41:
result.Level = BurnoutModerate
case result.Score >= 21:
result.Level = BurnoutMild
default:
result.Level = BurnoutHealthy
}
return result, nil
}
// computeTrend compares average work time of most recent 3 days vs the 3 before those.
func computeTrend(store *db.Store, userID int64, today string, loc *time.Location) (pts int, direction string) {
t, err := time.Parse(DateLayout, today)
if err != nil {
return 0, "unknown"
}
var recent, older [3]int64
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 1)).Format(DateLayout)
recent[2-i] = dayWorkSeconds(store, userID, d, loc)
}
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 4)).Format(DateLayout)
older[2-i] = dayWorkSeconds(store, userID, d, loc)
}
var recentAvg, olderAvg float64
for _, v := range recent {
recentAvg += float64(v)
}
for _, v := range older {
olderAvg += float64(v)
}
recentAvg /= 3.0
olderAvg /= 3.0
if recentAvg > olderAvg*1.2 {
return 15, fmt.Sprintf("+%.0f%%", math.Round((recentAvg-olderAvg)/math.Max(olderAvg, 1)*100))
}
if olderAvg > recentAvg*1.2 {
return 0, fmt.Sprintf("-%.0f%%", math.Round((olderAvg-recentAvg)/math.Max(olderAvg, 1)*100))
}
return 5, "stable"
}
func dayWorkSeconds(store *db.Store, userID int64, date string, loc *time.Location) int64 {
events, err := store.EventsForDayByDate(userID, date)
if err != nil || len(events) == 0 {
return 0
}
day, err := store.GetDay(userID, date)
if err != nil || day == nil {
return 0
}
y, m, d := parseGregorianDateKey(date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
return totals.TotalSeconds
}
// buildBurnoutText returns a formatted burnout assessment.
func (h *Handler) buildBurnoutText(userID int64, loc *time.Location) string {
res, err := h.assessBurnout(userID, loc)
if err != nil {
return "Burnout assessment unavailable."
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Burnout Assessment\nLevel: %s (%d/100)\n\n", res.Level, res.Score))
if res.ConsecutiveDays > 0 {
b.WriteString(fmt.Sprintf(" Consecutive workdays: %d days (%d pts)\n", res.ConsecutiveDays, res.ConsecutivePts))
}
if res.WorkSeconds > 0 {
wh := float64(res.WorkSeconds) / secondsPerHour
b.WriteString(fmt.Sprintf(" Today's work: %.1f hours (%d pts)\n", wh, res.LongHoursPts))
}
if res.BreakSeconds > 0 {
br := float64(res.BreakSeconds)
total := float64(res.WorkSeconds + res.BreakSeconds)
ratio := br / total * 100
b.WriteString(fmt.Sprintf(" Break ratio: %.0f%% (%d pts)\n", ratio, res.BreakRatioPts))
}
if res.WeekendPts > 0 {
b.WriteString(fmt.Sprintf(" Weekend work: yes (%d pts)\n", res.WeekendPts))
}
b.WriteString(fmt.Sprintf(" Work trend: %s (%d pts)\n", res.TrendDirection, res.TrendPts))
if res.LateNightPts > 0 {
b.WriteString(fmt.Sprintf(" Late-night work: yes (%d pts)\n", res.LateNightPts))
}
b.WriteString("\nRecommendations:\n")
switch res.Level {
case BurnoutHealthy:
b.WriteString(" Keep up the balanced routine!")
case BurnoutMild:
b.WriteString(" Consider taking short breaks throughout the day.")
b.WriteString("\n Make sure to disconnect after work hours.")
case BurnoutModerate:
b.WriteString(" Consider taking a day off soon.")
b.WriteString("\n Review your workload distribution.")
b.WriteString("\n Ensure you are taking adequate breaks.")
case BurnoutHigh:
b.WriteString(" Strongly consider taking time off.")
b.WriteString("\n Your workload patterns indicate potential strain.")
b.WriteString("\n Please prioritize rest and recovery.")
}
return b.String()
}
// burnoutAssessment returns the burnout assessment text.
func (h *Handler) burnoutAssessment(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
loc := loadLocation(user.Timezone)
return h.buildBurnoutText(user.ID, loc), nil
}

View File

@@ -65,123 +65,138 @@ func (h *Handler) handleHistoryCallback(ctx context.Context, chatID int64, msgID
return return
} }
var y, m int if h.handleHistoryCalendarNav(ctx, chatID, msgID, data) {
var date string return
var eid int64 }
if h.handleHistoryYearPicker(ctx, chatID, msgID, data) {
return
}
if h.handleHistoryDayView(ctx, chatID, msgID, user, data, loc) {
return
}
if h.handleHistoryEventEdit(ctx, chatID, msgID, user, data, loc, callbackID) {
return
}
if h.handleHistoryEventAdd(ctx, chatID, msgID, user, data, loc) {
return
}
}
// Navigate to previous month // handleHistoryCalendarNav handles calendar navigation callbacks.
func (h *Handler) handleHistoryCalendarNav(ctx context.Context, chatID int64, msgID int, data string) bool {
var y, m int
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m) h.editCalendar(ctx, chatID, msgID, y, m)
return return true
} }
// Navigate to next month
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m) h.editCalendar(ctx, chatID, msgID, y, m)
return return true
} }
// Navigate to previous year
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 { if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m) h.editCalendar(ctx, chatID, msgID, y, m)
return return true
} }
// Navigate to next year
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 { if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m) h.editCalendar(ctx, chatID, msgID, y, m)
return return true
} }
// Show year picker from calendar return false
}
// handleHistoryYearPicker handles year picker callbacks.
func (h *Handler) handleHistoryYearPicker(ctx context.Context, chatID int64, msgID int, data string) bool {
var y, m, startY, refY, refM int
if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 { if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 {
h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y) h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y)
return return true
} }
// Navigate year block in picker
var startY, refY, refM int
if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 { if n, _ := fmt.Sscanf(data, "cal_years_%d_%d_%d", &startY, &refY, &refM); n == 3 {
h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY) h.editCalendarYearPicker(ctx, chatID, msgID, startY+6, refM, refY)
return return true
} }
// Select year from picker
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 { if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
h.editCalendar(ctx, chatID, msgID, y, 1) h.editCalendar(ctx, chatID, msgID, y, 1)
return return true
} }
// Back from year picker to calendar
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 { if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m) h.editCalendar(ctx, chatID, msgID, y, m)
return return true
} }
// Open a specific day return false
}
// handleHistoryDayView handles day-view-related callbacks.
func (h *Handler) handleHistoryDayView(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool {
var date string
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 {
h.editDayView(ctx, chatID, msgID, user, date, loc) h.editDayView(ctx, chatID, msgID, user, date, loc)
return return true
} }
// Back to day view from event editing var eid int64
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc) h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return return true
} }
// Set event work type return false
var wtid int64 }
// handleHistoryEventEdit handles event editing callbacks.
func (h *Handler) handleHistoryEventEdit(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location, callbackID string) bool {
var eid, wtid int64
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc) h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc)
return return true
} }
// Show work type picker for an event
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
h.editEventType(ctx, chatID, msgID, user, eid, loc) h.editEventType(ctx, chatID, msgID, user, eid, loc)
return return true
} }
// Delete event confirmation prompt
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc) h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
return return true
} }
// Confirm event deletion
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc) h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
return return true
} }
// Show hour picker for event time
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
h.editEventTime(ctx, chatID, msgID, user, eid, loc) h.editEventTime(ctx, chatID, msgID, user, eid, loc)
return return true
} }
// Set event time (hour+minute)
var hh, mm int var hh, mm int
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 { if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc) h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, callbackID)
return return true
} }
// Show minute picker (hour already chosen)
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc) h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
return return true
} }
// Set note for an event — prompt user for text
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 { if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
h.promptNote(ctx, chatID, msgID, user, eid, loc) h.promptNote(ctx, chatID, msgID, user, eid, loc)
return return true
} }
// Cancel note prompt — return to day view
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 { if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc) h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return return true
} }
// Add IN event — show hour picker return false
if strings.HasPrefix(data, "addin_") { }
// handleHistoryEventAdd handles event-adding callbacks.
func (h *Handler) handleHistoryEventAdd(ctx context.Context, chatID int64, msgID int, user *db.User, data string, loc *time.Location) bool {
switch {
case strings.HasPrefix(data, "addin_"):
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc) h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
return case strings.HasPrefix(data, "addout_"):
}
// Add OUT event — show hour picker
if strings.HasPrefix(data, "addout_") {
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc) h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
return case strings.HasPrefix(data, "addtm_"):
}
// Add event hour/minute callbacks
if strings.HasPrefix(data, "addtm_") {
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc) h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
return default:
return false
} }
return true
} }
// sendCalendar sends a calendar view as a new message. // sendCalendar sends a calendar view as a new message.
@@ -245,7 +260,7 @@ func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, yea
cm := buildCalendarMonth(cal, year, month) cm := buildCalendarMonth(cal, year, month)
text := cm.title text := cm.title
todayKey := now.Format("2006-01-02") todayKey := now.Format(DateLayout)
hasEvent := make(map[string]bool) hasEvent := make(map[string]bool)
startDate, endDate := monthGregorianRange(cal, year, month) startDate, endDate := monthGregorianRange(cal, year, month)
@@ -365,7 +380,7 @@ func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user
text += "\n\nEvents:" text += "\n\nEvents:"
kbRows := [][]models.InlineKeyboardButton{} kbRows := [][]models.InlineKeyboardButton{}
for _, e := range events { for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
label := "IN" label := "IN"
if e.EventType == "out" { if e.EventType == "out" {
label = "OUT" label = "OUT"
@@ -435,7 +450,7 @@ func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int,
return return
} }
t := time.Unix(event.OccurredAt, 0).In(loc) t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false) h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
} }
// buildHourPickerKeyboard creates the 24-hour grid for time selection. // buildHourPickerKeyboard creates the 24-hour grid for time selection.
@@ -488,14 +503,14 @@ func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int,
} }
// editEventTimeSet applies a new time to an event, checking for overlaps. // editEventTimeSet applies a new time to an event, checking for overlaps.
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) { func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location, callbackID string) {
event, err := h.getEventByID(user.ID, eventID) event, err := h.getEventByID(user.ID, eventID)
if err != nil { if err != nil {
return return
} }
t := time.Unix(event.OccurredAt, 0).In(loc) t := time.Unix(event.OccurredAt, 0).In(loc)
newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc) newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc)
date := t.Format("2006-01-02") date := t.Format(DateLayout)
// Reject if the new time collides with another event's timestamp // Reject if the new time collides with another event's timestamp
events, err := h.DB.EventsForDayByDate(user.ID, date) events, err := h.DB.EventsForDayByDate(user.ID, date)
@@ -506,7 +521,7 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
} }
eTime := time.Unix(e.OccurredAt, 0) eTime := time.Unix(e.OccurredAt, 0)
if newTime.Unix() == eTime.Unix() { if newTime.Unix() == eTime.Unix() {
h.acknowledgeCallback(ctx, chatID, msgID) h.acknowledgeCallback(ctx, callbackID)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false) h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return return
} }
@@ -552,8 +567,8 @@ func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int,
} }
// acknowledgeCallback sends an empty acknowledgement for a callback query. // acknowledgeCallback sends an empty acknowledgement for a callback query.
func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) { func (h *Handler) acknowledgeCallback(ctx context.Context, callbackID string) {
h.answerCb(ctx, fmt.Sprintf("cb_%d_%d", chatID, msgID)) h.answerCb(ctx, callbackID)
} }
// promptNote asks the user to type a note for the event, then re-renders the day view. // promptNote asks the user to type a note for the event, then re-renders the day view.
@@ -562,7 +577,7 @@ func (h *Handler) promptNote(ctx context.Context, chatID int64, msgID int, user
if err != nil { if err != nil {
return return
} }
t := time.Unix(event.OccurredAt, 0).In(loc).Format("15:04") t := time.Unix(event.OccurredAt, 0).In(loc).Format(TimeLayout)
h.mu.Lock() h.mu.Lock()
h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()} h.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()}
@@ -596,7 +611,7 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in
return return
} }
t := time.Unix(event.OccurredAt, 0).In(loc) t := time.Unix(event.OccurredAt, 0).In(loc)
date := t.Format("2006-01-02") date := t.Format(DateLayout)
_, err = h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID) _, err = h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID)
if err != nil { if err != nil {
@@ -630,7 +645,7 @@ func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, us
return return
} }
t := time.Unix(event.OccurredAt, 0).In(loc) t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format("2006-01-02"), loc, false) h.sendDayView(ctx, chatID, msgID, user, t.Format(DateLayout), loc, false)
} }
// getEventByID fetches a single event by ID, scoped to the user. // getEventByID fetches a single event by ID, scoped to the user.

View File

@@ -24,7 +24,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
now := time.Now().In(loc) now := time.Now().In(loc)
lastTime := time.Unix(last.OccurredAt, 0).In(loc) lastTime := time.Unix(last.OccurredAt, 0).In(loc)
if lastTime.Format("2006-01-02") == now.Format("2006-01-02") { if lastTime.Format(DateLayout) == now.Format(DateLayout) {
return "", errors.New("already clocked in") return "", errors.New("already clocked in")
} }
} }
@@ -35,11 +35,12 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil { if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
return "", err return "", err
} }
return fmt.Sprintf("clocked in at %s", now.Format("15:04")), nil return fmt.Sprintf("clocked in at %s", now.Format(TimeLayout)), nil
} }
// clockOut records a clock-out event for today. // clockOut records a clock-out event for today.
// Returns the formatted time or an error if not clocked in. // Returns the formatted time or an error if not clocked in.
// Also awards XP and updates streaks if RPG is enabled.
func (h *Handler) clockOut(chatID int64) (string, error) { func (h *Handler) clockOut(chatID int64) (string, error) {
user, day, err := h.getUserToday(chatID) user, day, err := h.getUserToday(chatID)
if err != nil { if err != nil {
@@ -60,7 +61,30 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil { if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
return "", err return "", err
} }
return fmt.Sprintf("clocked out at %s", now.Format("15:04")), nil text := fmt.Sprintf("clocked out at %s", now.Format(TimeLayout))
// Award XP and update streaks if RPG enabled
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.RPGEnabled {
sessionWork := now.Unix() - last.OccurredAt
today := now.Format(DateLayout)
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
streak, _ := h.updateStreak(user.ID, today, true)
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(user.ID, sessionWork, streak, today)
stats, _ := h.DB.GetOrCreateRPGStats(user.ID)
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend)
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
if leveledUp {
text += "\nLevel up!"
}
for _, a := range unlocked {
text += fmt.Sprintf("\nAchievement: %s", a)
}
}
return text, nil
} }
// dayOff toggles today as a day off. // dayOff toggles today as a day off.
@@ -100,11 +124,15 @@ func (h *Handler) toggleReport(chatID int64) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
user.ReportEnabled = !user.ReportEnabled st, err := h.DB.GetOrCreateSettings(user.ID)
if err := h.DB.UpdateUser(user); err != nil { if err != nil {
return "", err return "", err
} }
if user.ReportEnabled { st.ReportEnabled = !st.ReportEnabled
if err := h.DB.UpdateSettings(st); err != nil {
return "", err
}
if st.ReportEnabled {
return "daily report enabled", nil return "daily report enabled", nil
} }
return "daily report disabled", nil return "daily report disabled", nil

View File

@@ -28,6 +28,152 @@ var themes = map[string]themeColors{
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"}, "catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
} }
type reportStyles struct {
title int
header int
data int
total int
dayOff int
}
func newReportStyles(f *excelize.File, theme themeColors) reportStyles {
return reportStyles{
title: newStyle(f, &excelize.Style{
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
header: newStyle(f, &excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
Border: []excelize.Border{
{Type: "left", Color: "FFFFFF", Style: 1},
{Type: "right", Color: "FFFFFF", Style: 1},
{Type: "top", Color: "FFFFFF", Style: 1},
{Type: "bottom", Color: "FFFFFF", Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
data: newStyle(f, &excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
total: newStyle(f, &excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 2},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
dayOff: newStyle(f, &excelize.Style{
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}),
}
}
func newStyle(f *excelize.File, s *excelize.Style) int {
id, _ := f.NewStyle(s)
return id
}
func writeReportHeader(f *excelize.File, sheet, title string, s reportStyles) {
f.SetCellValue(sheet, "A1", title)
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", s.title)
f.SetRowHeight(sheet, 1, 30)
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, h)
f.SetCellStyle(sheet, cell, cell, s.header)
}
f.SetRowHeight(sheet, 2, 22)
f.SetColWidth(sheet, "A", "A", 14)
f.SetColWidth(sheet, "B", "B", 10)
f.SetColWidth(sheet, "C", "C", 10)
f.SetColWidth(sheet, "D", "D", 12)
f.SetColWidth(sheet, "E", "E", 10)
f.SetColWidth(sheet, "F", "F", 10)
}
func writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []db.Event, day *db.Day, loc *time.Location, hasDayOff bool, s reportStyles) (workSec, breakSec int64) {
cellDate, _ := excelize.CoordinatesToCellName(1, row)
f.SetCellValue(sheet, cellDate, displayDate)
f.SetCellStyle(sheet, cellDate, cellDate, s.data)
if len(events) > 0 {
cellIn, _ := excelize.CoordinatesToCellName(2, row)
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format(TimeLayout))
f.SetCellStyle(sheet, cellIn, cellIn, s.data)
}
if len(events) > 0 {
lastEvent := events[len(events)-1]
if lastEvent.EventType == "out" {
cellOut, _ := excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format(TimeLayout))
f.SetCellStyle(sheet, cellOut, cellOut, s.data)
}
}
cellType, _ := excelize.CoordinatesToCellName(4, row)
f.SetCellValue(sheet, cellType, typeLabel)
f.SetCellStyle(sheet, cellType, cellType, s.data)
if len(events) > 0 && day != nil {
var d time.Time
d, _ = time.Parse(DateLayout, day.Date)
dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
workSec = totals.TotalSeconds
breakSec = totals.BreakSeconds
}
cellWork, _ := excelize.CoordinatesToCellName(5, row)
f.SetCellValue(sheet, cellWork, fmtHHMM(workSec))
f.SetCellStyle(sheet, cellWork, cellWork, s.data)
cellBreak, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec))
f.SetCellStyle(sheet, cellBreak, cellBreak, s.data)
if hasDayOff {
cellA, _ := excelize.CoordinatesToCellName(1, row)
cellF, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellStyle(sheet, cellA, cellF, s.dayOff)
}
return workSec, breakSec
}
func writeReportTotalRow(f *excelize.File, sheet string, row int, totalWork, totalBreak int64, s reportStyles) {
row++
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), s.total)
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork))
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), s.total)
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak))
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), s.total)
}
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) { func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
theme, ok := themes[accent] theme, ok := themes[accent]
if !ok { if !ok {
@@ -45,79 +191,15 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
f.SetActiveSheet(index) f.SetActiveSheet(index)
f.DeleteSheet("Sheet1") f.DeleteSheet("Sheet1")
titleStyle, _ := f.NewStyle(&excelize.Style{ styles := newReportStyles(f, theme)
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}},
Border: []excelize.Border{
{Type: "left", Color: "FFFFFF", Style: 1},
{Type: "right", Color: "FFFFFF", Style: 1},
{Type: "top", Color: "FFFFFF", Style: 1},
{Type: "bottom", Color: "FFFFFF", Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
dataStyle, _ := f.NewStyle(&excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
totalStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 2},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
dayOffStyle, _ := f.NewStyle(&excelize.Style{
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}},
Border: []excelize.Border{
{Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: theme.border, Style: 1},
},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
monthName := formatMonthTitle(calendar, calYear, calMonth) monthName := formatMonthTitle(calendar, calYear, calMonth)
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName)) writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
f.SetRowHeight(sheet, 1, 30)
headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"}
for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, h)
f.SetCellStyle(sheet, cell, cell, headerStyle)
}
f.SetRowHeight(sheet, 2, 22)
f.SetColWidth(sheet, "A", "A", 14)
f.SetColWidth(sheet, "B", "B", 10)
f.SetColWidth(sheet, "C", "C", 10)
f.SetColWidth(sheet, "D", "D", 12)
f.SetColWidth(sheet, "E", "E", 10)
f.SetColWidth(sheet, "F", "F", 10)
loc := loadLocation(timezone) loc := loadLocation(timezone)
row := 3 row := 3
userDays, err := store.GetUserDaysInRange(userID, start.Format("2006-01-02"), end.Format("2006-01-02")) userDays, err := store.GetUserDaysInRange(userID, start.Format(DateLayout), end.Format(DateLayout))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -130,10 +212,9 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
var totalWork, totalBreak int64 var totalWork, totalBreak int64
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02") dateStr := d.Format(DateLayout)
displayDate := formatDateForCalendar(dateStr, calendar) displayDate := formatDateForCalendar(dateStr, calendar)
// Determine if this day exists, its events, and day-off status.
var ( var (
day *db.Day day *db.Day
events []db.Event events []db.Event
@@ -148,26 +229,6 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
hasDayOff = day.IsDayOff hasDayOff = day.IsDayOff
} }
// Write the date cell for every day.
cellDate, _ := excelize.CoordinatesToCellName(1, row)
f.SetCellValue(sheet, cellDate, displayDate)
f.SetCellStyle(sheet, cellDate, cellDate, dataStyle)
if len(events) > 0 {
cellIn, _ := excelize.CoordinatesToCellName(2, row)
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format("15:04"))
f.SetCellStyle(sheet, cellIn, cellIn, dataStyle)
}
if len(events) > 0 {
lastEvent := events[len(events)-1]
if lastEvent.EventType == "out" {
cellOut, _ := excelize.CoordinatesToCellName(3, row)
f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format("15:04"))
f.SetCellStyle(sheet, cellOut, cellOut, dataStyle)
}
}
// Type column: day-off, work-type label, or empty.
var typeLabel string var typeLabel string
if hasDayOff { if hasDayOff {
if day.DayOffReason != "" { if day.DayOffReason != "" {
@@ -178,47 +239,14 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
} else if len(events) > 0 { } else if len(events) > 0 {
typeLabel = workTypeLabel(store, day, events) typeLabel = workTypeLabel(store, day, events)
} }
cellType, _ := excelize.CoordinatesToCellName(4, row)
f.SetCellValue(sheet, cellType, typeLabel)
f.SetCellStyle(sheet, cellType, cellType, dataStyle)
// Work and Break columns — compute totals only when there are events.
var workSec, breakSec int64
if len(events) > 0 && day != nil {
dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
workSec = totals.TotalSeconds
breakSec = totals.BreakSeconds
totalWork += workSec
totalBreak += breakSec
}
cellWork, _ := excelize.CoordinatesToCellName(5, row)
f.SetCellValue(sheet, cellWork, fmtHHMM(workSec))
f.SetCellStyle(sheet, cellWork, cellWork, dataStyle)
cellBreak, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellValue(sheet, cellBreak, fmtHHMM(breakSec))
f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle)
if hasDayOff {
cellA, _ := excelize.CoordinatesToCellName(1, row)
cellF, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellStyle(sheet, cellA, cellF, dayOffStyle)
}
ws, bs := writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles)
totalWork += ws
totalBreak += bs
row++ row++
} }
row++ writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle)
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork))
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle)
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak))
f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), totalStyle)
buf, err := f.WriteToBuffer() buf, err := f.WriteToBuffer()
if err != nil { if err != nil {
@@ -253,16 +281,16 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
} }
func fmtHHMM(seconds int64) string { func fmtHHMM(seconds int64) string {
hours := seconds / 3600 hours := seconds / secondsPerHour
mins := (seconds % 3600) / 60 mins := (seconds % secondsPerHour) / 60
return fmt.Sprintf("%d:%02d", hours, mins) return fmt.Sprintf("%d:%02d", hours, mins)
} }
func fmtDDHHMM(seconds int64) string { func fmtDDHHMM(seconds int64) string {
days := seconds / 86400 days := seconds / secondsPerDay
rem := seconds % 86400 rem := seconds % secondsPerDay
hours := rem / 3600 hours := rem / secondsPerHour
mins := (rem % 3600) / 60 mins := (rem % secondsPerHour) / 60
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins) return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
} }
@@ -310,18 +338,20 @@ func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *db.U
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth) startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse("2006-01-02", startStr) start, err := time.Parse(DateLayout, startStr)
if err != nil { if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc) start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
} }
end, err := time.Parse("2006-01-02", endStr) end, err := time.Parse(DateLayout, endStr)
if err != nil { if err != nil {
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc) startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
end = startOfMonth.AddDate(0, 1, -1) end = startOfMonth.AddDate(0, 1, -1)
} }
st, _ := h.DB.GetOrCreateSettings(user.ID)
accent := st.ExportAccent
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr) slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, accent, user.Calendar, calYear, calMonth, start, end)
if err != nil { if err != nil {
slog.Error("generate report", "error", err) slog.Error("generate report", "error", err)
h.sendText(ctx, chatID, "Error generating report") h.sendText(ctx, chatID, "Error generating report")
@@ -434,94 +464,102 @@ func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID
return return
} }
var y, m int if h.handleExportNav(ctx, chatID, msgID, user, data) {
return
}
h.handleExportDo(ctx, chatID, msgID, user, data)
}
// Navigate to previous year // handleExportNav handles year/month navigation callbacks for the export flow.
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *db.User, data string) bool {
var y int
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 { if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user) h.editExportMonthPicker(ctx, chatID, msgID, y-1, 0, user)
return return true
} }
// Navigate to next year
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 { if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user) h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user)
return return true
} }
// Show year picker from month picker
if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 { if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 {
h.editExportYearPicker(ctx, chatID, msgID, y, y) h.editExportYearPicker(ctx, chatID, msgID, y, y)
return return true
} }
// Navigate year block in picker
var refY int var refY int
if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 { if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 {
h.editExportYearPicker(ctx, chatID, msgID, y+6, refY) h.editExportYearPicker(ctx, chatID, msgID, y+6, refY)
return return true
} }
// Select year from picker
if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 { if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user) h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
return return true
} }
// Back from year picker to month picker
if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 { if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user) h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
return true
}
return false
}
// handleExportDo triggers the actual Excel export for a selected month.
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *db.User, data string) {
var y, m int
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
return return
} }
// Export a specific month last, err := h.DB.GetLastEvent(user.ID)
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 { if err == nil && last != nil && last.EventType == "in" {
last, err := h.DB.GetLastEvent(user.ID) backKB := models.InlineKeyboardMarkup{
if err == nil && last != nil && last.EventType == "in" {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &kb)
return
}
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
start, err := time.Parse("2006-01-02", startStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
end, err := time.Parse("2006-01-02", endStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
if err != nil {
slog.Error("generate report", "error", err)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, "Error generating report", &kb)
return
}
slog.Info("report generated", "bytes", len(data))
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{ InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}},
}, },
} }
h.editText(ctx, chatID, msgID, "Report ready:", &kb) h.editText(ctx, chatID, msgID, "You are currently clocked in. Please clock out first, then try again.", &backKB)
return
}
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{ startStr, endStr := monthGregorianRange(user.Calendar, y, m)
ChatID: chatID, start, err := time.Parse(DateLayout, startStr)
Document: &models.InputFileUpload{ if err != nil {
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)), h.sendText(ctx, chatID, "Error processing date")
Data: bytes.NewReader(data), return
}
end, err := time.Parse(DateLayout, endStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
reportData, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, st.ExportAccent, user.Calendar, y, m, start, end)
if err != nil {
slog.Error("generate report", "error", err)
backKB := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}, },
}); err != nil {
slog.Error("send document", "error", err)
} }
h.editText(ctx, chatID, msgID, "Error generating report", &backKB)
return
}
slog.Info("report generated", "bytes", len(reportData))
backKB := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, "Report ready:", &backKB)
if _, err := h.Bot.SendDocument(ctx, &bot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
Data: bytes.NewReader(reportData),
},
}); err != nil {
slog.Error("send document", "error", err)
} }
} }

View File

@@ -14,7 +14,21 @@ import (
"worktimeBot/internal/db" "worktimeBot/internal/db"
) )
const rateLimitInterval = 1 * time.Second const (
rateLimitInterval = 1 * time.Second
DateLayout = "2006-01-02"
TimeLayout = "15:04"
secondsPerHour = 3600
secondsPerDay = 86400
defaultBreakThreshold = 300
maxBreakThresholdMins = 480
streakBonusCap = 500
xpCurveMultiplier = 50
salaryRoundFactor = 100
maxHourlyRate = 999999
)
type pendingNote struct { type pendingNote struct {
eventID int64 eventID int64
@@ -129,10 +143,10 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
event, err := h.getEventByID(user.ID, pn.eventID) event, err := h.getEventByID(user.ID, pn.eventID)
if err == nil { if err == nil {
if err := h.DB.SetEventNote(pn.eventID, msg.Text); err != nil { if err := h.DB.SetEventNote(pn.eventID, SanitizeNote(msg.Text)); err != nil {
slog.Error("failed to save event note", "event_id", pn.eventID, "error", err) slog.Error("failed to save event note", "event_id", pn.eventID, "error", err)
} }
date := time.Unix(event.OccurredAt, 0).In(loc).Format("2006-01-02") date := time.Unix(event.OccurredAt, 0).In(loc).Format(DateLayout)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true) h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
} }
return return
@@ -183,6 +197,24 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
h.handleSummaryMsg(ctx, msg) h.handleSummaryMsg(ctx, msg)
case "/setbreak": case "/setbreak":
h.handleSetBreakMsg(ctx, msg) h.handleSetBreakMsg(ctx, msg)
case "/rpg":
h.handleActionMsg(ctx, msg, h.rpgStatus)
case "/achievements":
h.handleActionMsg(ctx, msg, h.achievementsList)
case "/salary":
h.handleActionMsg(ctx, msg, h.salaryEstimate)
case "/burnout":
h.handleActionMsg(ctx, msg, h.burnoutAssessment)
case "/league":
h.handleLeagueMsg(ctx, msg)
case "/setrate":
h.handleSetRateMsg(ctx, msg)
case "/setcurrency":
h.handleSetCurrencyMsg(ctx, msg)
case "/rpgtoggle":
h.handleRPGSettingsMsg(ctx, msg)
case "/leaguetoggle":
h.handleLeagueSettingsMsg(ctx, msg)
default: default:
h.sendText(ctx, msg.Chat.ID, "Unknown command") h.sendText(ctx, msg.Chat.ID, "Unknown command")
} }
@@ -232,8 +264,6 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
h.timezoneCallback(ctx, chatID, msgID, cb.ID) h.timezoneCallback(ctx, chatID, msgID, cb.ID)
case "history": case "history":
h.historyCallback(ctx, chatID, msgID, cb.ID) h.historyCallback(ctx, chatID, msgID, cb.ID)
case "reporttoggle":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.toggleReport)
case "reporttime": case "reporttime":
h.reportTimeCallback(ctx, chatID, msgID, cb.ID) h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
case "breakthreshold": case "breakthreshold":
@@ -242,29 +272,75 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
h.backToMenu(ctx, chatID, msgID, cb.ID) h.backToMenu(ctx, chatID, msgID, cb.ID)
case "back_settings": case "back_settings":
h.backToSettings(ctx, chatID, msgID, cb.ID) h.backToSettings(ctx, chatID, msgID, cb.ID)
case "rpg":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.rpgStatus)
case "achievements":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.achievementsList)
case "salary":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.salaryEstimate)
case "burnout":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.burnoutAssessment)
case "league":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "xp")
case "league_xp":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "xp")
case "league_level":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "level")
case "league_streak":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "streak")
case "league_hours":
h.handleLeagueCallback(ctx, chatID, msgID, cb.ID, "hours")
case "rpgtoggle":
h.rpgToggleCallback(ctx, chatID, msgID, cb.ID)
case "leaguetoggle":
h.leagueToggleCallback(ctx, chatID, msgID, cb.ID)
case "reporttoggle":
h.reportToggleCallback(ctx, chatID, msgID, cb.ID)
case "currency":
h.currencyCallback(ctx, chatID, msgID, cb.ID)
case "setrate":
h.rateCallback(ctx, chatID, msgID, cb.ID)
default: default:
// Delegate prefixed callbacks to the appropriate sub-handler h.handlePrefixedCallback(ctx, chatID, msgID, cb.ID, cb.Data)
if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" { }
h.selectWorkType(ctx, chatID, msgID, cb.ID, cb.Data[9:]) }
return
} func (h *Handler) handlePrefixedCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
if len(cb.Data) > 7 && cb.Data[:7] == "accent_" { if len(data) > 9 && data[:9] == "worktype_" {
h.selectAccent(ctx, chatID, msgID, cb.ID, cb.Data[7:]) h.selectWorkType(ctx, chatID, msgID, callbackID, data[9:])
return return
} }
if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" { if len(data) > 7 && data[:7] == "accent_" {
h.selectCalendar(ctx, chatID, msgID, cb.ID, cb.Data[8:]) h.selectAccent(ctx, chatID, msgID, callbackID, data[7:])
return return
} }
if len(cb.Data) > 4 && cb.Data[:4] == "exp_" { if len(data) > 8 && data[:8] == "caltype_" {
h.handleExportCallback(ctx, chatID, msgID, cb.ID, cb.Data) h.selectCalendar(ctx, chatID, msgID, callbackID, data[8:])
return return
} }
if len(cb.Data) > 15 && cb.Data[:15] == "breakthreshold_" { if len(data) > 4 && data[:4] == "exp_" {
h.selectBreakThreshold(ctx, chatID, msgID, cb.ID, cb.Data[15:]) h.handleExportCallback(ctx, chatID, msgID, callbackID, data)
return return
} }
h.handleHistoryCallback(ctx, chatID, msgID, cb.ID, cb.Data) if len(data) > 15 && data[:15] == "breakthreshold_" {
h.selectBreakThreshold(ctx, chatID, msgID, callbackID, data[15:])
return
}
switch data {
case "rpg_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "rpg_enable", true)
case "rpg_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "rpg_enable", false)
case "league_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "league_enable", true)
case "league_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "league_enable", false)
case "report_enable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "report_enable", true)
case "report_disable":
h.handleSelectCallback(ctx, chatID, msgID, callbackID, "report_enable", false)
default:
h.handleHistoryCallback(ctx, chatID, msgID, callbackID, data)
} }
} }
@@ -283,7 +359,7 @@ func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) {
if err != nil { if err != nil {
loc = time.UTC loc = time.UTC
} }
today := time.Now().In(loc).Format("2006-01-02") today := time.Now().In(loc).Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, today) day, err := h.DB.GetOrCreateDay(user.ID, today)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -313,16 +389,25 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
/worktype - Select work type /worktype - Select work type
/dayoff - Toggle day off /dayoff - Toggle day off
/report - Show today report /report - Show today report
/export [YYYY-MM] - Export monthly report (current month if omitted) /export [YYYY-MM] - Export monthly report
/history - View calendar history /history - View calendar history
/edit YYYY-MM-DD - Edit a specific day /edit YYYY-MM-DD - Edit a specific day
/note [YYYY-MM-DD] HH:MM <text> - Set note for an event /note [YYYY-MM-DD] HH:MM <text> - Set note for an event
/summary [YYYY-MM] - Show monthly summary (current month if omitted) /summary [YYYY-MM] - Show monthly summary
/reporttoggle - Enable/disable auto report /reporttoggle - Enable/disable auto report
/setreporttime HH:MM - Set daily report time /setreporttime HH:MM - Set daily report time
/setaccent - Select accent color /setaccent - Select accent color
/settimezone <name> - Set your timezone (e.g. Asia/Tehran) /settimezone <name> - Set timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set minimum break threshold in minutes /setbreak <minutes> - Set break threshold in minutes
/rpg - Show RPG stats and XP
/achievements - View unlocked achievements
/salary - Show salary estimate
/burnout - View burnout assessment
/league - View WorkTime League rankings
/setrate <amount> - Set hourly rate (e.g. 25.50)
/setcurrency <symbol> [code] - Set currency (e.g. $ USD)
/rpgtoggle - Enable/disable RPG system
/leaguetoggle - Enable/disable league participation
Dates use your calendar type (Gregorian/Jalali/Hijri). Dates use your calendar type (Gregorian/Jalali/Hijri).
Buttons on the main menu also work.` Buttons on the main menu also work.`
@@ -354,7 +439,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
timeStr = parts[2] timeStr = parts[2]
noteParts = parts[3:] noteParts = parts[3:]
} else { } else {
dateStr = time.Now().In(loc).Format("2006-01-02") dateStr = time.Now().In(loc).Format(DateLayout)
timeStr = parts[1] timeStr = parts[1]
noteParts = parts[2:] noteParts = parts[2:]
} }
@@ -367,7 +452,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
var targetEvent *db.Event var targetEvent *db.Event
for i := range events { for i := range events {
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format("15:04") t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(TimeLayout)
if t == timeStr { if t == timeStr {
targetEvent = &events[i] targetEvent = &events[i]
break break
@@ -378,7 +463,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
return return
} }
note := strings.Join(noteParts, " ") note := SanitizeNote(strings.Join(noteParts, " "))
if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil { if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving note") h.sendText(ctx, msg.Chat.ID, "Error saving note")
return return
@@ -470,8 +555,15 @@ func mainKeyboard() models.InlineKeyboardMarkup {
{ {
{Text: "Report", CallbackData: "report"}, {Text: "Report", CallbackData: "report"},
{Text: "Export", CallbackData: "export"}, {Text: "Export", CallbackData: "export"},
{Text: "History", CallbackData: "history"},
}, },
{ {
{Text: "RPG", CallbackData: "rpg"},
{Text: "League", CallbackData: "league"},
{Text: "Burnout", CallbackData: "burnout"},
},
{
{Text: "Salary", CallbackData: "salary"},
{Text: "Settings", CallbackData: "settings"}, {Text: "Settings", CallbackData: "settings"},
}, },
}, },
@@ -489,8 +581,8 @@ func backKeyboard() models.InlineKeyboardMarkup {
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m"). // formatDuration formats seconds as a human-readable duration (e.g. "7h 30m").
func formatDuration(seconds int64) string { func formatDuration(seconds int64) string {
hours := seconds / 3600 hours := seconds / secondsPerHour
mins := (seconds % 3600) / 60 mins := (seconds % secondsPerHour) / 60
if hours > 0 { if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins) return fmt.Sprintf("%dh %dm", hours, mins)
} }

157
internal/bot/league.go Normal file
View File

@@ -0,0 +1,157 @@
package bot
import (
"context"
"fmt"
"strings"
"github.com/go-telegram/bot/models"
)
// LeagueSortOption represents a valid league sort column.
type LeagueSortOption string
const (
LeagueSortXP LeagueSortOption = "xp"
LeagueSortLevel LeagueSortOption = "level"
LeagueSortStreak LeagueSortOption = "streak"
LeagueSortHours LeagueSortOption = "hours"
)
// buildLeagueText returns the formatted league rankings.
func (h *Handler) buildLeagueText(orderBy LeagueSortOption) string {
entries, err := h.DB.GetLeagueRankings(string(orderBy))
if err != nil {
return "League rankings unavailable."
}
if len(entries) == 0 {
return "No participants in the league yet.\nEnable League participation in Settings to join!"
}
var b strings.Builder
orderLabel := map[string]string{
"xp": "XP",
"level": "Level",
"streak": "Streak",
"hours": "Total Hours",
}
b.WriteString(fmt.Sprintf("WorkTime League — sorted by %s\n\n", orderLabel[string(orderBy)]))
// Determine column widths
maxUserLen := 0
for _, e := range entries {
l := len(e.Username)
if l > maxUserLen {
maxUserLen = l
}
}
if maxUserLen < 8 {
maxUserLen = 8
}
if maxUserLen > 20 {
maxUserLen = 20
}
header := fmt.Sprintf("%-3s %-*s %8s %6s %6s %12s", "#", maxUserLen, "Username", "XP", "Level", "Streak", "Hours")
b.WriteString(header + "\n")
b.WriteString(strings.Repeat("-", len(header)) + "\n")
for i, e := range entries {
rank := i + 1
name := e.Username
if len(name) > maxUserLen {
name = name[:maxUserLen]
}
b.WriteString(fmt.Sprintf("%-3d %-*s %8d %6d %6d %12.1f\n",
rank, maxUserLen, name, e.XP, e.Level, e.Streak, e.TotalHours))
}
b.WriteString(fmt.Sprintf("\n%d participant(s)\n", len(entries)))
return b.String()
}
// buildLeagueRankText returns the user's personal rank in the league.
func (h *Handler) buildLeagueRankText(userID int64) string {
entries, err := h.DB.GetLeagueRankings("xp")
if err != nil || len(entries) == 0 {
return ""
}
for i, e := range entries {
if e.UserID == userID {
return fmt.Sprintf("League rank: #%d of %d", i+1, len(entries))
}
}
return ""
}
// handleLeagueMsg handles the /league command.
func (h *Handler) handleLeagueMsg(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.LeagueOptIn {
h.sendText(ctx, msg.Chat.ID, "League participation is disabled.\nEnable it in Settings or use /leaguetoggle.")
return
}
text := h.buildLeagueText(LeagueSortXP)
kb := h.buildLeagueKeyboard("xp")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleLeagueCallback handles league inline view.
func (h *Handler) handleLeagueCallback(ctx context.Context, chatID int64, msgID int, callbackID, orderBy string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.LeagueOptIn {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "League participation is disabled.\nEnable it in Settings to view rankings.", &kb)
return
}
text := h.buildLeagueText(LeagueSortOption(orderBy))
kb := h.buildLeagueKeyboard(orderBy)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildLeagueKeyboard creates the league navigation keyboard.
func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup {
orders := []struct {
label string
data string
}{
{"XP", "league_xp"},
{"Level", "league_level"},
{"Streak", "league_streak"},
{"Hours", "league_hours"},
}
rows := [][]models.InlineKeyboardButton{}
for _, o := range orders {
label := o.label
if o.data == "league_"+currentOrder {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: o.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}

View File

@@ -32,7 +32,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
last, err := h.DB.GetLastEvent(user.ID) last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" { if err == nil && last != nil && last.EventType == "in" {
loc := loadLocation(user.Timezone) loc := loadLocation(user.Timezone)
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04") t := time.Unix(last.OccurredAt, 0).In(loc).Format(TimeLayout)
text += fmt.Sprintf("\nStatus: working since %s", t) text += fmt.Sprintf("\nStatus: working since %s", t)
} else { } else {
text += "\nStatus: not working" text += "\nStatus: not working"
@@ -49,14 +49,33 @@ func (h *Handler) buildStatusText(chatID int64) string {
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
} }
loc := loadLocation(user.Timezone) // Salary info if configured
weekTotal := h.computeWeekTotals(user, loc) st, _ := h.DB.GetOrCreateSettings(user.ID)
if weekTotal > 0 { if st.HourlyRate > 0 {
text += fmt.Sprintf("\nWeek: %s", formatDuration(weekTotal)) if events, err := h.DB.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 {
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
dayEnd := now.Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
salLine := h.buildSalaryStatus(user.ID, totals.TotalSeconds)
if salLine != "" {
text += salLine
}
}
} }
if day.MinBreakThreshold > 0 { // RPG info if enabled
text += fmt.Sprintf("\nBreak threshold: %dm", day.MinBreakThreshold/60) if st.RPGEnabled {
stats, _ := h.DB.GetOrCreateRPGStats(user.ID)
if stats != nil {
text += fmt.Sprintf("\nLevel: %d | XP: %d", stats.Level, stats.XP)
rankText := h.buildLeagueRankText(user.ID)
if rankText != "" {
text += fmt.Sprintf(" | %s", rankText)
}
}
} }
text += "\n\nChoose an action:" text += "\n\nChoose an action:"
@@ -87,7 +106,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
y, m, d := parseGregorianDateKey(day.Date) y, m, d := parseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix() dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
now := time.Now().In(loc) now := time.Now().In(loc)
todayStr := now.Format("2006-01-02") todayStr := now.Format(DateLayout)
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
if day.Date == todayStr { if day.Date == todayStr {
dayEnd = now.Unix() dayEnd = now.Unix()
@@ -101,7 +120,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
lines += "\n\n" lines += "\n\n"
for _, e := range events { for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") t := time.Unix(e.OccurredAt, 0).In(loc).Format(TimeLayout)
label := "IN" label := "IN"
if e.EventType == "out" { if e.EventType == "out" {
label = "OUT" label = "OUT"
@@ -122,6 +141,13 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
formatDuration(totals.TotalSeconds), formatDuration(totals.TotalSeconds),
formatDuration(totals.BreakSeconds)) formatDuration(totals.BreakSeconds))
// Salary info if configured
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
est := computeDailySalary(totals.TotalSeconds, st.HourlyRate)
lines += fmt.Sprintf(" Salary: %s\n", SalaryEstimate{GrossEarning: est}.formatSalary(st.Currency))
}
return lines return lines
} }
@@ -137,7 +163,7 @@ func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int6
loc = time.UTC loc = time.UTC
} }
now := time.Now().In(loc) now := time.Now().In(loc)
today := now.Format("2006-01-02") today := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, today) day, err := h.DB.GetOrCreateDay(user.ID, today)
if err != nil { if err != nil {

316
internal/bot/rpg.go Normal file
View File

@@ -0,0 +1,316 @@
package bot
import (
"fmt"
"log/slog"
"strings"
"time"
"worktimeBot/internal/db"
)
// XP earned per second of work.
const xpPerSecond int64 = 1
// xpForLevel returns the total XP required to reach level n.
// Quadratic curve: totalXP(n) = n * (n + 1) * xpCurveMultiplier
// Level 1 = 100 XP, Level 10 = 5500 XP, Level 50 = 127500 XP
func xpForLevel(n int) int64 {
return int64(n) * int64(n+1) * xpCurveMultiplier
}
// levelForXP returns the level for a given total XP amount.
func levelForXP(xp int64) int {
l := 1
for xpForLevel(l) <= xp {
l++
}
return l - 1
}
// xpForWorkSeconds calculates XP earned for a given amount of work.
// Base: 1 XP per second.
func xpForWorkSeconds(sec int64) int64 {
return sec / xpPerSecond
}
// streakBonus returns bonus XP for maintaining a streak.
// +xpCurveMultiplier XP per day of current streak, capped at streakBonusCap.
func streakBonus(streak int) int64 {
b := int64(streak) * xpCurveMultiplier
if b > streakBonusCap {
b = streakBonusCap
}
return b
}
// computeAndAwardXP calculates XP for a work session and updates the DB.
// Returns the new total XP and whether the user leveled up.
func (h *Handler) computeAndAwardXP(userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, err error) {
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err != nil {
return 0, false, 0, err
}
baseXP := xpForWorkSeconds(workSeconds)
bonus := streakBonus(streak)
totalXP := baseXP + bonus
if totalXP < 0 {
totalXP = 0
}
stats.XP += totalXP
newLevel = levelForXP(stats.XP)
leveledUp = newLevel > stats.Level
stats.Level = newLevel
stats.TotalWorkSeconds += workSeconds
if err := h.DB.UpdateRPGStats(stats); err != nil {
return 0, false, 0, err
}
if err := h.DB.UpsertDailyXP(userID, date, totalXP, workSeconds); err != nil {
slog.Warn("failed to upsert daily xp", "user_id", userID, "error", err)
}
return stats.XP, leveledUp, newLevel, nil
}
// updateStreak advances or resets the user's streak based on today's activity.
// Returns the updated streak count.
func (h *Handler) updateStreak(userID int64, date string, hasWorkToday bool) (int, error) {
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err != nil {
return 0, err
}
if !hasWorkToday {
stats.CurrentStreak = 0
if err := h.DB.UpdateRPGStats(stats); err != nil {
return 0, err
}
return 0, nil
}
// Parse yesterday to check continuity
t, err := time.Parse(DateLayout, date)
if err != nil {
return 0, err
}
yesterday := t.AddDate(0, 0, -1).Format(DateLayout)
prevEvents, err := h.DB.EventsForDayByDate(userID, yesterday)
hadWorkYesterday := err == nil && len(prevEvents) > 0
if hadWorkYesterday {
stats.CurrentStreak++
} else {
stats.CurrentStreak = 1
}
if stats.CurrentStreak > stats.LongestStreak {
stats.LongestStreak = stats.CurrentStreak
}
if err := h.DB.UpdateRPGStats(stats); err != nil {
return 0, err
}
return stats.CurrentStreak, nil
}
// checkAchievements evaluates and unlocks any newly earned achievements.
func (h *Handler) checkAchievements(userID int64, stats *db.RPGStats, workSeconds int64, date string, isWeekend bool) []string {
unlocker := newAchievementUnlocker(h, userID)
h.checkEarlyAchievements(unlocker, workSeconds)
h.checkEventTimeAchievements(unlocker, userID, date)
h.checkStreakAchievements(unlocker, stats)
h.checkHourAchievements(unlocker, stats)
h.checkSessionAchievements(unlocker, workSeconds, isWeekend)
h.checkLevelAchievements(unlocker, stats)
return unlocker.unlocked
}
type achievementUnlocker struct {
h *Handler
userID int64
unlocked []string
}
func newAchievementUnlocker(h *Handler, userID int64) *achievementUnlocker {
return &achievementUnlocker{h: h, userID: userID}
}
func (u *achievementUnlocker) unlock(code string) {
a, err := u.h.DB.GetAchievementByCode(code)
if err != nil {
return
}
ok, err := u.h.DB.UnlockAchievement(u.userID, a.ID)
if err != nil {
slog.Error("unlock achievement", "code", code, "error", err)
return
}
if ok {
u.unlocked = append(u.unlocked, a.Name)
}
}
func (h *Handler) checkEarlyAchievements(u *achievementUnlocker, workSeconds int64) {
has, _ := h.DB.HasAchievement(u.userID, "first_clockin")
if !has && workSeconds > 0 {
u.unlock("first_clockin")
}
}
func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) {
t, _ := time.Parse(DateLayout, date)
dayStart := t.Unix()
dayEnd := t.Add(24 * time.Hour).Unix()
events, err := h.DB.EventsForDayByDate(userID, date)
if err != nil {
return
}
for _, e := range events {
if e.OccurredAt < dayStart || e.OccurredAt > dayEnd {
continue
}
et := time.Unix(e.OccurredAt, 0)
hh, mm, _ := et.Clock()
minutes := hh*60 + mm
if e.EventType == "in" && minutes < 7*60 {
u.unlock("early_bird")
}
if e.EventType == "out" && minutes >= 22*60 {
u.unlock("night_owl")
}
}
}
func (h *Handler) checkStreakAchievements(u *achievementUnlocker, stats *db.RPGStats) {
if stats.CurrentStreak >= 5 {
u.unlock("iron_streak_5")
}
if stats.CurrentStreak >= 10 {
u.unlock("iron_streak_10")
}
if stats.CurrentStreak >= 30 {
u.unlock("iron_streak_30")
}
}
func (h *Handler) checkHourAchievements(u *achievementUnlocker, stats *db.RPGStats) {
hoursWorked := float64(stats.TotalWorkSeconds) / secondsPerHour
if hoursWorked >= 100.0 {
u.unlock("hundred_hours")
}
if hoursWorked >= 500.0 {
u.unlock("five_hundred_hours")
}
}
func (h *Handler) checkSessionAchievements(u *achievementUnlocker, workSeconds int64, isWeekend bool) {
if workSeconds > 10*secondsPerHour {
u.unlock("overtime_hero")
}
if isWeekend && workSeconds > 0 {
u.unlock("weekend_warrior")
}
}
func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *db.RPGStats) {
if stats.Level >= 5 {
u.unlock("level_5")
}
if stats.Level >= 10 {
u.unlock("level_10")
}
if stats.Level >= 25 {
u.unlock("level_25")
}
if stats.Level >= 50 {
u.unlock("level_50")
}
}
// buildRPGStatus returns a formatted string showing the user's RPG stats.
func (h *Handler) buildRPGStatus(userID int64) string {
stats, err := h.DB.GetOrCreateRPGStats(userID)
if err != nil {
return "RPG stats unavailable"
}
nextLevelXP := xpForLevel(stats.Level + 1)
currentLevelXP := xpForLevel(stats.Level)
xpIntoLevel := stats.XP - currentLevelXP
xpNeeded := nextLevelXP - currentLevelXP
var b strings.Builder
b.WriteString(fmt.Sprintf("Level: %d\n", stats.Level))
b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded))
b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak))
hoursTotal := float64(stats.TotalWorkSeconds) / secondsPerHour
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
// Show achievements count
achs, _ := h.DB.GetUserAchievements(userID)
if len(achs) > 0 {
b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs)))
}
return b.String()
}
// buildAchievementsList returns the user's unlocked achievements.
func (h *Handler) buildAchievementsList(userID int64) string {
achs, err := h.DB.GetUserAchievements(userID)
if err != nil {
return "Achievements unavailable"
}
if len(achs) == 0 {
return "No achievements unlocked yet.\nWork consistently to earn them!"
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Achievements (%d):\n\n", len(achs)))
for _, a := range achs {
t := time.Unix(a.UnlockedAt, 0)
b.WriteString(fmt.Sprintf(" %s\n %s - %s\n", a.Name, a.Description, t.Format(DateLayout)))
}
return b.String()
}
// rpgStatus returns the user's RPG stats.
func (h *Handler) rpgStatus(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if !st.RPGEnabled {
return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil
}
return h.buildRPGStatus(user.ID), nil
}
// achievementsList returns the user's unlocked achievements.
func (h *Handler) achievementsList(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if !st.RPGEnabled {
return "RPG system is disabled. Enable it in Settings or use /rpgtoggle.", nil
}
return h.buildAchievementsList(user.ID), nil
}

173
internal/bot/rpg_test.go Normal file
View File

@@ -0,0 +1,173 @@
package bot
import (
"testing"
)
func TestXpForLevel(t *testing.T) {
cases := []struct {
n int
want int64
}{
{0, 0},
{1, 100},
{2, 300},
{5, 1500},
{10, 5500},
{27, 37800},
{50, 127500},
}
for _, c := range cases {
got := xpForLevel(c.n)
if got != c.want {
t.Errorf("xpForLevel(%d) = %d, want %d", c.n, got, c.want)
}
}
}
func TestLevelForXP(t *testing.T) {
cases := []struct {
xp int64
want int
}{
{0, 0},
{50, 0},
{99, 0},
{100, 1},
{299, 1},
{300, 2},
{5500, 10},
{37800, 27},
{127500, 50},
}
for _, c := range cases {
got := levelForXP(c.xp)
if got != c.want {
t.Errorf("levelForXP(%d) = %d, want %d", c.xp, got, c.want)
}
}
}
func TestLevelForXP_Monotonic(t *testing.T) {
for n := 0; n <= 100; n++ {
xp := xpForLevel(n)
l := levelForXP(xp)
if l != n {
t.Errorf("levelForXP(xpForLevel(%d)) = %d, want %d", n, l, n)
}
}
}
func TestXpForWorkSeconds(t *testing.T) {
if got := xpForWorkSeconds(0); got != 0 {
t.Errorf("xpForWorkSeconds(0) = %d, want 0", got)
}
if got := xpForWorkSeconds(3600); got != 3600 {
t.Errorf("xpForWorkSeconds(3600) = %d, want 3600", got)
}
if got := xpForWorkSeconds(1800); got != 1800 {
t.Errorf("xpForWorkSeconds(1800) = %d, want 1800", got)
}
}
func TestStreakBonus(t *testing.T) {
cases := []struct {
streak int
want int64
}{
{0, 0},
{1, 50},
{10, 500},
{5, 250},
{20, 500},
}
for _, c := range cases {
got := streakBonus(c.streak)
if got != c.want {
t.Errorf("streakBonus(%d) = %d, want %d", c.streak, got, c.want)
}
}
}
func TestCurrencySymbol(t *testing.T) {
cases := []struct {
currency string
want string
}{
{"", "$"},
{"$", "$"},
{"$ USD", "$"},
{"€ EUR", "€"},
{"£", "£"},
{"Toman", "Toman"},
{" ", "$"},
}
for _, c := range cases {
got := currencySymbol(c.currency)
if got != c.want {
t.Errorf("currencySymbol(%q) = %q, want %q", c.currency, got, c.want)
}
}
}
func TestComputeDailySalary(t *testing.T) {
// 1 hour at $25/hr
got := computeDailySalary(3600, 25.0)
if got != 25.0 {
t.Errorf("computeDailySalary(3600, 25) = %.2f, want 25.00", got)
}
// 2.5 hours at $20/hr = $50
got = computeDailySalary(9000, 20.0)
if got != 50.0 {
t.Errorf("computeDailySalary(9000, 20) = %.2f, want 50.00", got)
}
// 0 seconds = $0
got = computeDailySalary(0, 50.0)
if got != 0 {
t.Errorf("computeDailySalary(0, 50) = %.2f, want 0", got)
}
// rounding: 3599 sec (0.9997 hr) at $10/hr = $9.997 -> $10.00
got = computeDailySalary(3599, 10.0)
if got != 10.0 {
t.Errorf("computeDailySalary(3599, 10) = %.2f, want 10.00", got)
}
}
func TestSanitizeNote(t *testing.T) {
// Trims spaces
if got := SanitizeNote(" hello "); got != "hello" {
t.Errorf("SanitizeNote trim: got %q", got)
}
// Removes control chars except newline/tab
if got := SanitizeNote("he\x00llo\nworld\t!"); got != "hello\nworld\t!" {
t.Errorf("SanitizeNote control: got %q", got)
}
// Preserves newlines and tabs
if got := SanitizeNote("line1\nline2\tindented"); got != "line1\nline2\tindented" {
t.Errorf("SanitizeNote newline/tab: got %q", got)
}
// Truncates long notes
long := string(make([]byte, 600))
for i := range long {
long = long[:i] + "a" + long[i+1:]
}
got := SanitizeNote(long)
if len(got) > 500 {
t.Errorf("SanitizeNote length: got %d, want <= 500", len(got))
}
}
func TestSanitizeDisplayName(t *testing.T) {
if got := SanitizeDisplayName(" Alice "); got != "Alice" {
t.Errorf("SanitizeDisplayName trim: got %q", got)
}
if got := SanitizeDisplayName("Ali\x00ce"); got != "Alice" {
t.Errorf("SanitizeDisplayName control: got %q", got)
}
}

259
internal/bot/salary.go Normal file
View File

@@ -0,0 +1,259 @@
package bot
import (
"context"
"fmt"
"math"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/db"
)
// SalaryEstimate holds a salary calculation result.
type SalaryEstimate struct {
Currency string
HourlyRate float64
WorkSeconds int64
WorkHours float64
GrossEarning float64
WorkDays int
}
// computeDailySalary calculates salary for a single day's work.
func computeDailySalary(workSeconds int64, hourlyRate float64) float64 {
hours := float64(workSeconds) / secondsPerHour
return math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor
}
// computeMonthlySalary calculates salary for a range of days.
func computeMonthlySalary(days []db.Day, store *db.Store, userID int64, hourlyRate float64, loc *time.Location) SalaryEstimate {
var totalWork int64
workDayCount := 0
for _, day := range days {
if day.IsDayOff {
continue
}
events, err := store.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
continue
}
y, m, d := parseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds > 0 {
totalWork += totals.TotalSeconds
workDayCount++
}
}
hours := float64(totalWork) / secondsPerHour
gross := math.Round(hours*hourlyRate*salaryRoundFactor) / salaryRoundFactor
return SalaryEstimate{
WorkSeconds: totalWork,
WorkHours: math.Round(hours*salaryRoundFactor) / salaryRoundFactor,
GrossEarning: gross,
WorkDays: workDayCount,
}
}
// currencySymbol extracts the first token as symbol, defaulting to "$".
func currencySymbol(currency string) string {
if parts := strings.Fields(currency); len(parts) >= 1 && parts[0] != "" {
return parts[0]
}
return "$"
}
// formatSalary formats a salary estimate into a readable string.
func (e SalaryEstimate) formatSalary(currency string) string {
sym := currencySymbol(currency)
code := "USD"
if parts := strings.Fields(currency); len(parts) >= 2 {
code = parts[1]
}
return fmt.Sprintf("%s%.2f %s", sym, e.GrossEarning, code)
}
// formatSalaryShort returns just the symbol + amount.
func (e SalaryEstimate) formatSalaryShort(currency string) string {
return fmt.Sprintf("%s%.2f", currencySymbol(currency), e.GrossEarning)
}
// buildSalaryStatus returns a formatted daily salary line.
func (h *Handler) buildSalaryStatus(userID int64, workSeconds int64) string {
st, err := h.DB.GetOrCreateSettings(userID)
if err != nil || st.HourlyRate <= 0 {
return ""
}
est := computeDailySalary(workSeconds, st.HourlyRate)
s := SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate}
return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.formatSalaryShort(st.Currency), st.HourlyRate)
}
// salaryEstimate returns the salary estimate for the current month.
func (h *Handler) salaryEstimate(chatID int64) (string, error) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return "", err
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return "", err
}
if st.HourlyRate <= 0 {
return "No hourly rate configured.\nUse /setrate <amount> to set your rate.", nil
}
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = gregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = gregorianToHijri(calY, calM, now.Day())
}
startStr, endStr := monthGregorianRange(user.Calendar, calY, calM)
days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "", err
}
est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc)
est.Currency = st.Currency
est.HourlyRate = st.HourlyRate
title := formatMonthTitle(user.Calendar, calY, calM)
return fmt.Sprintf("Salary estimate for %s\n%s\nRate: %s%.2f/hr\nDays worked: %d\nHours: %.1f",
title, est.formatSalary(st.Currency), currencySymbol(st.Currency), st.HourlyRate,
est.WorkDays, est.WorkHours), nil
}
// handleSetRateMsg handles /setrate <amount>.
func (h *Handler) handleSetRateMsg(ctx context.Context, msg *models.Message) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate"))
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current hourly rate: %s%.2f\nUsage: /setrate <amount> (e.g. /setrate 25.50)", currencySymbol(st.Currency), st.HourlyRate))
} else {
h.sendText(ctx, msg.Chat.ID, "No rate configured.\nUsage: /setrate <amount> (e.g. /setrate 25.50)")
}
return
}
var rate float64
if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > maxHourlyRate {
h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)")
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving rate")
return
}
st.HourlyRate = float64(int(rate*salaryRoundFactor)) / 100.0
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving rate")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", currencySymbol(st.Currency), st.HourlyRate))
}
// handleSetCurrencyMsg handles /setcurrency <symbol> [code].
func (h *Handler) handleSetCurrencyMsg(ctx context.Context, msg *models.Message) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
if args == "" {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current currency: %s\nUsage: /setcurrency <symbol> [code]\nExamples:\n/setcurrency $ USD\n/setcurrency EUR\n/setcurrency Toman", st.Currency))
return
}
parts := strings.Fields(args)
if len(parts[0]) > 10 || strings.ContainsFunc(parts[0], func(r rune) bool { return r < 32 || r > 126 }) {
h.sendText(ctx, msg.Chat.ID, "Invalid currency symbol. Use a short printable string like $, €, £, or Toman.")
return
}
currency := parts[0]
if len(parts) >= 2 {
currency = parts[0] + " " + parts[1]
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving currency")
return
}
st.Currency = currency
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving currency")
return
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency))
}
// currencyCallback shows currency info (inline).
func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
text := fmt.Sprintf("Currency: %s\n\nUse /setcurrency <symbol> [code] to change it.\nExamples:\n/setcurrency $ USD\n/setcurrency EUR\n/setcurrency Toman", st.Currency)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// rateCallback shows rate info (inline).
func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, _ := h.DB.GetOrCreateSettings(user.ID)
var text string
if st.HourlyRate > 0 {
text = fmt.Sprintf("Hourly rate: %s%.2f\n\nUse /setrate <amount> to change it.\nExample: /setrate 25.50", currencySymbol(st.Currency), st.HourlyRate)
} else {
text = "No hourly rate configured.\n\nUse /setrate <amount> to set your rate.\nExample: /setrate 25.50"
}
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}

41
internal/bot/sanitize.go Normal file
View File

@@ -0,0 +1,41 @@
package bot
import (
"strings"
"unicode"
)
const maxNoteLength = 500
const maxUsernameLength = 64
// SanitizeNote cleans user-provided note text: trims whitespace, limits length,
// and removes control characters (keeps newlines).
func SanitizeNote(s string) string {
s = strings.TrimSpace(s)
if len(s) > maxNoteLength {
s = s[:maxNoteLength]
}
return strings.Map(func(r rune) rune {
if r == '\n' || r == '\t' || r == ' ' {
return r
}
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}
// SanitizeDisplayName cleans a display name (username) for safe rendering.
func SanitizeDisplayName(s string) string {
s = strings.TrimSpace(s)
if len(s) > maxUsernameLength {
s = s[:maxUsernameLength]
}
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, s)
}

View File

@@ -15,7 +15,7 @@ import (
// handleSetReportTime parses a HH:MM argument and updates the user's report time. // handleSetReportTime parses a HH:MM argument and updates the user's report time.
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) { func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) {
args := "" args := ""
if len(msg.Text) > 16 { // "/setreporttime " is 15 chars if len(msg.Text) > 16 {
args = strings.TrimSpace(msg.Text[15:]) args = strings.TrimSpace(msg.Text[15:])
} }
if args == "" { if args == "" {
@@ -24,7 +24,8 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message)
h.sendText(ctx, msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return return
} }
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime)) st, _ := h.DB.GetOrCreateSettings(user.ID)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", st.ReportTime))
return return
} }
if len(args) != 5 || args[2] != ':' { if len(args) != 5 || args[2] != ':' {
@@ -42,8 +43,13 @@ func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message)
h.sendText(ctx, msg.Chat.ID, "Error updating report time") h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return return
} }
user.ReportTime = args st, err := h.DB.GetOrCreateSettings(user.ID)
if err := h.DB.UpdateUser(user); err != nil { if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return
}
st.ReportTime = args
if err := h.DB.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating report time") h.sendText(ctx, msg.Chat.ID, "Error updating report time")
return return
} }
@@ -57,7 +63,12 @@ func (h *Handler) handleAccentMsg(ctx context.Context, msg *models.Message) {
h.sendText(ctx, msg.Chat.ID, "Error loading profile") h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return return
} }
text, kb := h.buildAccentKeyboard(user) st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildAccentKeyboard(st)
h.sendWithKB(ctx, msg.Chat.ID, text, kb) h.sendWithKB(ctx, msg.Chat.ID, text, kb)
} }
@@ -92,20 +103,23 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) {
return return
} }
now := time.Now().In(loc) now := time.Now().In(loc)
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format("15:04"))) h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now.Format(TimeLayout)))
} }
// getTodayBreakThreshold returns today's break threshold in seconds for the given chat. // getTodayBreakThreshold returns today's break threshold in seconds for the given chat.
func (h *Handler) getTodayBreakThreshold(chatID int64) int64 { func (h *Handler) getTodayBreakThreshold(chatID int64) int64 {
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return 300 return defaultBreakThreshold
} }
now := time.Now() loc, err := time.LoadLocation(user.Timezone)
date := now.Format("2006-01-02") if err != nil {
loc = time.UTC
}
date := time.Now().In(loc).Format(DateLayout)
day, err := h.DB.GetDay(user.ID, date) day, err := h.DB.GetDay(user.ID, date)
if err != nil { if err != nil {
return 300 return defaultBreakThreshold
} }
return day.MinBreakThreshold return day.MinBreakThreshold
} }
@@ -133,7 +147,7 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
return return
} }
now := time.Now() now := time.Now()
date := now.Format("2006-01-02") date := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, date) day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil { if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error updating break threshold") h.sendText(ctx, msg.Chat.ID, "Error updating break threshold")
@@ -149,30 +163,45 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
// settingsCallback shows the settings inline menu. // settingsCallback shows the settings inline menu.
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID) bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt) text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
} }
// buildSettingsKeyboard returns the settings menu text and inline keyboard. // buildSettingsKeyboard returns the settings menu text and inline keyboard.
func (h *Handler) buildSettingsKeyboard(user *db.User, breakThreshold int64) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildSettingsKeyboard(user *db.User, st *db.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
reportStatus := "disabled" reportStatus := "disabled"
if user.ReportEnabled { if st.ReportEnabled {
reportStatus = "enabled" reportStatus = "enabled"
} }
rpgStatus := "disabled"
if st.RPGEnabled {
rpgStatus = "enabled"
}
leagueStatus := "disabled"
if st.LeagueOptIn {
leagueStatus = "on"
}
rows := [][]models.InlineKeyboardButton{ rows := [][]models.InlineKeyboardButton{
{{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}}, {{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"}},
{{Text: fmt.Sprintf("Accent: %s", user.ExportAccent), CallbackData: "accent"}}, {{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"}},
{{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}}, {{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"}},
{{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}}, {{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"}},
{{Text: fmt.Sprintf("Report time: %s", user.ReportTime), CallbackData: "reporttime"}}, {{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"}},
{{Text: fmt.Sprintf("Break threshold: %dm", breakThreshold/60), CallbackData: "breakthreshold"}}, {{Text: fmt.Sprintf("Break threshold: %dm", breakThreshold/60), CallbackData: "breakthreshold"}},
{{Text: "History", CallbackData: "history"}}, {{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"}},
{{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"}},
{{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}},
{{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}},
} }
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
@@ -180,18 +209,22 @@ func (h *Handler) buildSettingsKeyboard(user *db.User, breakThreshold int64) (st
// accentCallback shows the accent color picker (inline edit). // accentCallback shows the accent color picker (inline edit).
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text, kb := h.buildAccentKeyboard(user) st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildAccentKeyboard(st)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
} }
// timezoneCallback shows the current timezone and instructions to change it. // timezoneCallback shows the current timezone and instructions to change it.
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -206,7 +239,7 @@ func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int,
} }
// buildAccentKeyboard returns the accent color picker keyboard. // buildAccentKeyboard returns the accent color picker keyboard.
func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeyboardMarkup) { func (h *Handler) buildAccentKeyboard(st *db.UserSettings) (string, models.InlineKeyboardMarkup) {
type accentOption struct { type accentOption struct {
name string name string
color string color string
@@ -220,7 +253,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
rows := [][]models.InlineKeyboardButton{} rows := [][]models.InlineKeyboardButton{}
for _, a := range accents { for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color) label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == user.ExportAccent { if a.name == st.ExportAccent {
label = "> " + label label = "> " + label
} }
rows = append(rows, []models.InlineKeyboardButton{ rows = append(rows, []models.InlineKeyboardButton{
@@ -235,7 +268,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
// selectAccent sets the user's export accent color after validation. // selectAccent sets the user's export accent color after validation.
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) { func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -244,8 +277,12 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
if !valid[name] { if !valid[name] {
return return
} }
user.ExportAccent = name st, err := h.DB.GetOrCreateSettings(user.ID)
if err := h.DB.UpdateUser(user); err != nil { if err != nil {
return
}
st.ExportAccent = name
if err := h.DB.UpdateSettings(st); err != nil {
return return
} }
kb := models.InlineKeyboardMarkup{ kb := models.InlineKeyboardMarkup{
@@ -258,12 +295,16 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
// reportTimeCallback shows the current report time setting. // reportTimeCallback shows the current report time setting.
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime) st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", st.ReportTime)
kb := models.InlineKeyboardMarkup{ kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{ InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}}, {{Text: "Back to Settings", CallbackData: "back_settings"}},
@@ -274,7 +315,7 @@ func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID in
// breakThresholdCallback shows the break threshold picker. // breakThresholdCallback shows the break threshold picker.
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
bt := h.getTodayBreakThreshold(chatID) bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildBreakThresholdKeyboard(bt) text, kb := h.buildBreakThresholdKeyboard(bt)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
@@ -309,9 +350,9 @@ func (h *Handler) buildBreakThresholdKeyboard(current int64) (string, models.Inl
// selectBreakThreshold saves the chosen break threshold and returns to settings. // selectBreakThreshold saves the chosen break threshold and returns to settings.
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, callbackID, valStr string) { func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, callbackID, valStr string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
mins, err := strconv.Atoi(valStr) mins, err := strconv.Atoi(valStr)
if err != nil || mins < 0 || mins > 480 { if err != nil || mins < 0 || mins > maxBreakThresholdMins {
return return
} }
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
@@ -319,7 +360,7 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
return return
} }
now := time.Now() now := time.Now()
date := now.Format("2006-01-02") date := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, date) day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil { if err != nil {
return return
@@ -329,25 +370,33 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
return return
} }
bt := h.getTodayBreakThreshold(chatID) bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt) st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
} }
// backToSettings returns to the main settings menu. // backToSettings returns to the main settings menu.
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
} }
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID) bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt) text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
} }
// calTypeCallback shows the calendar type selector. // calTypeCallback shows the calendar type selector.
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) { func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID) user, err := h.getOrCreateUser(chatID)
if err != nil { if err != nil {
return return
@@ -382,7 +431,7 @@ func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKey
// selectCalendar sets the user's calendar after validation. // selectCalendar sets the user's calendar after validation.
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) { func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, callbackID, name string) {
h.answerCb(ctx, callbackID) defer h.answerCb(ctx, callbackID)
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[name] { if !valid[name] {
return return
@@ -396,6 +445,185 @@ func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, c
return return
} }
bt := h.getTodayBreakThreshold(chatID) bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt) st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// handleRPGSettingsMsg shows the RPG enable/disable picker (command).
func (h *Handler) handleRPGSettingsMsg(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildRPGToggleKeyboard(user.ID)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// handleLeagueSettingsMsg shows the League enable/disable picker (command).
func (h *Handler) handleLeagueSettingsMsg(ctx context.Context, msg *models.Message) {
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
return
}
text, kb := h.buildLeagueToggleKeyboard(user.ID)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// rpgToggleCallback shows the RPG enable/disable picker (inline).
func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildRPGToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// leagueToggleCallback shows the League enable/disable picker (inline).
func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildLeagueToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// reportToggleCallback shows the Report enable/disable picker (inline) — replaces simple toggle.
func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, callbackID string) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
text, kb := h.buildReportToggleKeyboard(user.ID)
h.editText(ctx, chatID, msgID, text, &kb)
}
// buildRPGToggleKeyboard returns the RPG enable/disable picker.
func (h *Handler) buildRPGToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := false
if err == nil {
current = st.RPGEnabled
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "rpg_enable", true},
{"Disabled", "rpg_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "RPG System:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// buildLeagueToggleKeyboard returns the League enable/disable picker.
func (h *Handler) buildLeagueToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := false
if err == nil {
current = st.LeagueOptIn
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "league_enable", true},
{"Disabled", "league_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "WorkTime League:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// buildReportToggleKeyboard returns the Report enable/disable picker.
func (h *Handler) buildReportToggleKeyboard(userID int64) (string, models.InlineKeyboardMarkup) {
st, err := h.DB.GetOrCreateSettings(userID)
current := true
if err == nil {
current = st.ReportEnabled
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range []struct {
label string
data string
value bool
}{
{"Enabled", "report_enable", true},
{"Disabled", "report_disable", false},
} {
label := opt.label
if opt.value == current {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Daily Report:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
// handleSelectCallback handles binary select callbacks (report_enable, rpg_enable, etc.).
func (h *Handler) handleSelectCallback(ctx context.Context, chatID int64, msgID int, callbackID string, setting string, value bool) {
defer h.answerCb(ctx, callbackID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
switch setting {
case "report_enable":
st.ReportEnabled = value
case "rpg_enable":
st.RPGEnabled = value
if value {
h.DB.GetOrCreateRPGStats(user.ID)
}
case "league_enable":
st.LeagueOptIn = value
}
if err := h.DB.UpdateSettings(st); err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb) h.editText(ctx, chatID, msgID, text, &kb)
} }

View File

@@ -108,6 +108,14 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
if totalBreak > 0 { if totalBreak > 0 {
b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak))) b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak)))
} }
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc)
b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)",
est.formatSalary(st.Currency), est.WorkHours, est.WorkDays))
}
b.WriteString("\n\nDetails:\n") b.WriteString("\n\nDetails:\n")
for _, d := range dayDetails { for _, d := range dayDetails {
b.WriteString(d) b.WriteString(d)
@@ -115,39 +123,3 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
} }
return b.String() return b.String()
} }
// computeWeekTotals returns the total work seconds for the current week (Mon-Sun containing today).
func (h *Handler) computeWeekTotals(user *db.User, loc *time.Location) int64 {
now := time.Now().In(loc)
weekday := now.Weekday()
if weekday == time.Sunday {
weekday = 7
}
monday := now.AddDate(0, 0, -int(weekday-time.Monday))
sunday := monday.AddDate(0, 0, 6)
startStr := monday.Format("2006-01-02")
endStr := sunday.Format("2006-01-02")
days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return 0
}
var total int64
for _, day := range days {
if day.IsDayOff {
continue
}
events, err := h.DB.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
continue
}
y, m, d := parseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
total += totals.TotalSeconds
}
return total
}

View File

@@ -1,5 +1,3 @@
// Package bot implements the Telegram bot logic: message routing, time tracking,
// calendar rendering, Excel export, and inline editing of events.
package bot package bot
import ( import (

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 ( import (
"database/sql" "database/sql"
"embed" "embed"
"fmt"
"log/slog" "log/slog"
"time" "time"
@@ -21,21 +22,75 @@ type Store struct {
db *sql.DB 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 { type User struct {
ID int64 ID int64
ChatID int64 ChatID int64
Timezone string Username string
IsActive bool 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 ReportEnabled bool
ReportTime string ReportTime string
LastReportDate *string LastReportDate string
ExportAccent string RPGEnabled bool
Calendar string LeagueOptIn bool
Currency string
HourlyRate float64
CreatedAt int64 CreatedAt int64
UpdatedAt 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. // WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
type WorkType struct { type WorkType struct {
ID int64 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. // GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists.
func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT OR IGNORE INTO users (chat_id) VALUES (?)", "INSERT OR IGNORE INTO users (chat_id, username) VALUES (?, ?)",
chatID, chatID, randomUsername(),
) )
if err != nil { if err != nil {
return nil, err 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. // GetUserByChatID retrieves a single user by their Telegram chat ID.
func (s *Store) GetUserByChatID(chatID int64) (*User, error) { func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
row := s.db.QueryRow(` row := s.db.QueryRow(`
SELECT id, chat_id, timezone, is_active, report_enabled, report_time, SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
last_report_date, export_accent, calendar, created_at, updated_at
FROM users WHERE chat_id = ? FROM users WHERE chat_id = ?
`, chatID) `, chatID)
var u User var u User
var lastReport sql.NullString
err := row.Scan( err := row.Scan(
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
if lastReport.Valid {
u.LastReportDate = &lastReport.String
}
return &u, nil 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 { func (s *Store) UpdateUser(user *User) error {
_, err := s.db.Exec( _, 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=?", "UPDATE users SET username=?, timezone=?, is_active=?, calendar=?, updated_at=unixepoch() WHERE id=?",
user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID, user.Username, user.Timezone, user.IsActive, 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,
) )
return err return err
} }
@@ -400,8 +464,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error {
// GetAllUsers returns all users in the database. // GetAllUsers returns all users in the database.
func (s *Store) GetAllUsers() ([]User, error) { func (s *Store) GetAllUsers() ([]User, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, chat_id, timezone, is_active, report_enabled, report_time, SELECT id, chat_id, username, timezone, is_active, calendar, created_at, updated_at
last_report_date, export_accent, calendar, created_at, updated_at
FROM users FROM users
`) `)
if err != nil { if err != nil {
@@ -412,21 +475,289 @@ func (s *Store) GetAllUsers() ([]User, error) {
var users []User var users []User
for rows.Next() { for rows.Next() {
var u User var u User
var lastReport sql.NullString
if err := rows.Scan( if err := rows.Scan(
&u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, &u.ID, &u.ChatID, &u.Username, &u.Timezone, &u.IsActive,
&lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, &u.Calendar, &u.CreatedAt, &u.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if lastReport.Valid {
u.LastReportDate = &lastReport.String
}
users = append(users, u) users = append(users, u)
} }
return users, rows.Err() 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. // GetWorkTypes returns all work types ordered by ID.
func (s *Store) GetWorkTypes() ([]WorkType, error) { func (s *Store) GetWorkTypes() ([]WorkType, error) {
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC") rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")