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

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
}
var y, m int
var date string
var eid int64
if h.handleHistoryCalendarNav(ctx, chatID, msgID, data) {
return
}
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Set event work type
var wtid int64
return false
}
// 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 {
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 {
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 {
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Confirm event deletion
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
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 {
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Set event time (hour+minute)
var hh, mm int
if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 {
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc)
return
h.editEventTimeSet(ctx, chatID, msgID, user, eid, hh, mm, loc, callbackID)
return true
}
// Show minute picker (hour already chosen)
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
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 {
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 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
return true
}
// Add IN event — show hour picker
if strings.HasPrefix(data, "addin_") {
return false
}
// 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)
return
}
// Add OUT event — show hour picker
if strings.HasPrefix(data, "addout_") {
case strings.HasPrefix(data, "addout_"):
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
return
}
// Add event hour/minute callbacks
if strings.HasPrefix(data, "addtm_") {
case strings.HasPrefix(data, "addtm_"):
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
return
default:
return false
}
return true
}
// 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)
text := cm.title
todayKey := now.Format("2006-01-02")
todayKey := now.Format(DateLayout)
hasEvent := make(map[string]bool)
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:"
kbRows := [][]models.InlineKeyboardButton{}
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"
if e.EventType == "out" {
label = "OUT"
@@ -435,7 +450,7 @@ func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int,
return
}
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.
@@ -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.
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)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(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
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)
if newTime.Unix() == eTime.Unix() {
h.acknowledgeCallback(ctx, chatID, msgID)
h.acknowledgeCallback(ctx, callbackID)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
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.
func (h *Handler) acknowledgeCallback(ctx context.Context, chatID int64, msgID int) {
h.answerCb(ctx, fmt.Sprintf("cb_%d_%d", chatID, msgID))
func (h *Handler) acknowledgeCallback(ctx context.Context, callbackID string) {
h.answerCb(ctx, callbackID)
}
// 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 {
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.pendingNotes[chatID] = &pendingNote{eventID: eventID, createdAt: time.Now()}
@@ -596,7 +611,7 @@ func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID in
return
}
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)
if err != nil {
@@ -630,7 +645,7 @@ func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, us
return
}
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.

View File

@@ -24,7 +24,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
loc := loadLocation(user.Timezone)
now := time.Now().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")
}
}
@@ -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 {
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.
// 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) {
user, day, err := h.getUserToday(chatID)
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 {
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.
@@ -100,11 +124,15 @@ func (h *Handler) toggleReport(chatID int64) (string, error) {
if err != nil {
return "", err
}
user.ReportEnabled = !user.ReportEnabled
if err := h.DB.UpdateUser(user); err != nil {
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
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 disabled", nil

View File

@@ -28,6 +28,152 @@ var themes = map[string]themeColors{
"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) {
theme, ok := themes[accent]
if !ok {
@@ -45,79 +191,15 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
f.SetActiveSheet(index)
f.DeleteSheet("Sheet1")
titleStyle, _ := f.NewStyle(&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"},
})
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"},
})
styles := newReportStyles(f, theme)
monthName := formatMonthTitle(calendar, calYear, calMonth)
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
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)
writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
loc := loadLocation(timezone)
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 {
return nil, err
}
@@ -130,10 +212,9 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
var totalWork, totalBreak int64
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)
// Determine if this day exists, its events, and day-off status.
var (
day *db.Day
events []db.Event
@@ -148,26 +229,6 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
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
if hasDayOff {
if day.DayOffReason != "" {
@@ -178,47 +239,14 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
} else if len(events) > 0 {
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++
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)
writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
buf, err := f.WriteToBuffer()
if err != nil {
@@ -253,16 +281,16 @@ func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string {
}
func fmtHHMM(seconds int64) string {
hours := seconds / 3600
mins := (seconds % 3600) / 60
hours := seconds / secondsPerHour
mins := (seconds % secondsPerHour) / 60
return fmt.Sprintf("%d:%02d", hours, mins)
}
func fmtDDHHMM(seconds int64) string {
days := seconds / 86400
rem := seconds % 86400
hours := rem / 3600
mins := (rem % 3600) / 60
days := seconds / secondsPerDay
rem := seconds % secondsPerDay
hours := rem / secondsPerHour
mins := (rem % secondsPerHour) / 60
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)
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse("2006-01-02", startStr)
start, err := time.Parse(DateLayout, startStr)
if err != nil {
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 {
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
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)
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 {
slog.Error("generate report", "error", err)
h.sendText(ctx, chatID, "Error generating report")
@@ -434,94 +464,102 @@ func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID
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 {
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 {
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 {
h.editExportYearPicker(ctx, chatID, msgID, y, y)
return
return true
}
// Navigate year block in picker
var refY int
if n, _ := fmt.Sscanf(data, "exp_years_%d_%d", &y, &refY); n == 2 {
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 {
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 {
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
}
// Export a specific month
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 {
last, err := h.DB.GetLastEvent(user.ID)
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{
last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
backKB := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{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{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
Data: bytes.NewReader(data),
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
start, err := time.Parse(DateLayout, startStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
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"
)
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 {
eventID int64
@@ -129,10 +143,10 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
loc := loadLocation(user.Timezone)
event, err := h.getEventByID(user.ID, pn.eventID)
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)
}
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)
}
return
@@ -183,6 +197,24 @@ func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
h.handleSummaryMsg(ctx, msg)
case "/setbreak":
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:
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)
case "history":
h.historyCallback(ctx, chatID, msgID, cb.ID)
case "reporttoggle":
h.handleActionCallback(ctx, chatID, msgID, cb.ID, h.toggleReport)
case "reporttime":
h.reportTimeCallback(ctx, chatID, msgID, cb.ID)
case "breakthreshold":
@@ -242,29 +272,75 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
h.backToMenu(ctx, chatID, msgID, cb.ID)
case "back_settings":
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:
// Delegate prefixed callbacks to the appropriate sub-handler
if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" {
h.selectWorkType(ctx, chatID, msgID, cb.ID, cb.Data[9:])
return
}
if len(cb.Data) > 7 && cb.Data[:7] == "accent_" {
h.selectAccent(ctx, chatID, msgID, cb.ID, cb.Data[7:])
return
}
if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" {
h.selectCalendar(ctx, chatID, msgID, cb.ID, cb.Data[8:])
return
}
if len(cb.Data) > 4 && cb.Data[:4] == "exp_" {
h.handleExportCallback(ctx, chatID, msgID, cb.ID, cb.Data)
return
}
if len(cb.Data) > 15 && cb.Data[:15] == "breakthreshold_" {
h.selectBreakThreshold(ctx, chatID, msgID, cb.ID, cb.Data[15:])
return
}
h.handleHistoryCallback(ctx, chatID, msgID, cb.ID, cb.Data)
h.handlePrefixedCallback(ctx, chatID, msgID, cb.ID, cb.Data)
}
}
func (h *Handler) handlePrefixedCallback(ctx context.Context, chatID int64, msgID int, callbackID, data string) {
if len(data) > 9 && data[:9] == "worktype_" {
h.selectWorkType(ctx, chatID, msgID, callbackID, data[9:])
return
}
if len(data) > 7 && data[:7] == "accent_" {
h.selectAccent(ctx, chatID, msgID, callbackID, data[7:])
return
}
if len(data) > 8 && data[:8] == "caltype_" {
h.selectCalendar(ctx, chatID, msgID, callbackID, data[8:])
return
}
if len(data) > 4 && data[:4] == "exp_" {
h.handleExportCallback(ctx, chatID, msgID, callbackID, data)
return
}
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 {
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)
if err != nil {
return nil, nil, err
@@ -313,16 +389,25 @@ func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
/worktype - Select work type
/dayoff - Toggle day off
/report - Show today report
/export [YYYY-MM] - Export monthly report (current month if omitted)
/export [YYYY-MM] - Export monthly report
/history - View calendar history
/edit YYYY-MM-DD - Edit a specific day
/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
/setreporttime HH:MM - Set daily report time
/setaccent - Select accent color
/settimezone <name> - Set your timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set minimum break threshold in minutes
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
/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).
Buttons on the main menu also work.`
@@ -354,7 +439,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
timeStr = parts[2]
noteParts = parts[3:]
} else {
dateStr = time.Now().In(loc).Format("2006-01-02")
dateStr = time.Now().In(loc).Format(DateLayout)
timeStr = parts[1]
noteParts = parts[2:]
}
@@ -367,7 +452,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
var targetEvent *db.Event
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 {
targetEvent = &events[i]
break
@@ -378,7 +463,7 @@ func (h *Handler) handleNoteMsg(ctx context.Context, msg *models.Message) {
return
}
note := strings.Join(noteParts, " ")
note := SanitizeNote(strings.Join(noteParts, " "))
if err := h.DB.SetEventNote(targetEvent.ID, note); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving note")
return
@@ -470,8 +555,15 @@ func mainKeyboard() models.InlineKeyboardMarkup {
{
{Text: "Report", CallbackData: "report"},
{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"},
},
},
@@ -489,8 +581,8 @@ func backKeyboard() models.InlineKeyboardMarkup {
// formatDuration formats seconds as a human-readable duration (e.g. "7h 30m").
func formatDuration(seconds int64) string {
hours := seconds / 3600
mins := (seconds % 3600) / 60
hours := seconds / secondsPerHour
mins := (seconds % secondsPerHour) / 60
if hours > 0 {
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)
if err == nil && last != nil && last.EventType == "in" {
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)
} else {
text += "\nStatus: not working"
@@ -49,14 +49,33 @@ func (h *Handler) buildStatusText(chatID int64) string {
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
}
loc := loadLocation(user.Timezone)
weekTotal := h.computeWeekTotals(user, loc)
if weekTotal > 0 {
text += fmt.Sprintf("\nWeek: %s", formatDuration(weekTotal))
// Salary info if configured
st, _ := h.DB.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
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 {
text += fmt.Sprintf("\nBreak threshold: %dm", day.MinBreakThreshold/60)
// RPG info if enabled
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:"
@@ -87,7 +106,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
y, m, d := parseGregorianDateKey(day.Date)
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
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()
if day.Date == todayStr {
dayEnd = now.Unix()
@@ -101,7 +120,7 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event
lines += "\n\n"
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"
if e.EventType == "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.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
}
@@ -137,7 +163,7 @@ func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int6
loc = time.UTC
}
now := time.Now().In(loc)
today := now.Format("2006-01-02")
today := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, today)
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.
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message) {
args := ""
if len(msg.Text) > 16 { // "/setreporttime " is 15 chars
if len(msg.Text) > 16 {
args = strings.TrimSpace(msg.Text[15:])
}
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")
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
}
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")
return
}
user.ReportTime = args
if err := h.DB.UpdateUser(user); err != nil {
st, err := h.DB.GetOrCreateSettings(user.ID)
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")
return
}
@@ -57,7 +63,12 @@ func (h *Handler) handleAccentMsg(ctx context.Context, msg *models.Message) {
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
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)
}
@@ -92,20 +103,23 @@ func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message) {
return
}
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.
func (h *Handler) getTodayBreakThreshold(chatID int64) int64 {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return 300
return defaultBreakThreshold
}
now := time.Now()
date := now.Format("2006-01-02")
loc, err := time.LoadLocation(user.Timezone)
if err != nil {
loc = time.UTC
}
date := time.Now().In(loc).Format(DateLayout)
day, err := h.DB.GetDay(user.ID, date)
if err != nil {
return 300
return defaultBreakThreshold
}
return day.MinBreakThreshold
}
@@ -133,7 +147,7 @@ func (h *Handler) handleSetBreakMsg(ctx context.Context, msg *models.Message) {
return
}
now := time.Now()
date := now.Format("2006-01-02")
date := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil {
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.
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)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// 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"
if user.ReportEnabled {
if st.ReportEnabled {
reportStatus = "enabled"
}
rpgStatus := "disabled"
if st.RPGEnabled {
rpgStatus = "enabled"
}
leagueStatus := "disabled"
if st.LeagueOptIn {
leagueStatus = "on"
}
rows := [][]models.InlineKeyboardButton{
{{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("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: "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"}},
}
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).
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)
if err != nil {
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)
}
// timezoneCallback shows the current timezone and instructions to change it.
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)
if err != nil {
return
@@ -206,7 +239,7 @@ func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int,
}
// 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 {
name string
color string
@@ -220,7 +253,7 @@ func (h *Handler) buildAccentKeyboard(user *db.User) (string, models.InlineKeybo
rows := [][]models.InlineKeyboardButton{}
for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == user.ExportAccent {
if a.name == st.ExportAccent {
label = "> " + label
}
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.
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)
if err != nil {
return
@@ -244,8 +277,12 @@ func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cal
if !valid[name] {
return
}
user.ExportAccent = name
if err := h.DB.UpdateUser(user); err != nil {
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ExportAccent = name
if err := h.DB.UpdateSettings(st); err != nil {
return
}
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.
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)
if err != nil {
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{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{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.
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)
text, kb := h.buildBreakThresholdKeyboard(bt)
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.
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)
if err != nil || mins < 0 || mins > 480 {
if err != nil || mins < 0 || mins > maxBreakThresholdMins {
return
}
user, err := h.getOrCreateUser(chatID)
@@ -319,7 +360,7 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
return
}
now := time.Now()
date := now.Format("2006-01-02")
date := now.Format(DateLayout)
day, err := h.DB.GetOrCreateDay(user.ID, date)
if err != nil {
return
@@ -329,25 +370,33 @@ func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID
return
}
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)
}
// backToSettings returns to the main settings menu.
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)
if err != nil {
return
}
st, err := h.DB.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(chatID)
text, kb := h.buildSettingsKeyboard(user, bt)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// calTypeCallback shows the calendar type selector.
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)
if err != nil {
return
@@ -382,7 +431,7 @@ func (h *Handler) buildCalendarKeyboard(user *db.User) (string, models.InlineKey
// selectCalendar sets the user's calendar after validation.
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}
if !valid[name] {
return
@@ -396,6 +445,185 @@ func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, c
return
}
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)
}

View File

@@ -108,6 +108,14 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
if totalBreak > 0 {
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")
for _, d := range dayDetails {
b.WriteString(d)
@@ -115,39 +123,3 @@ func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, e
}
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
import (