fix: resolve go vet error and dayStart logic in ComputeDailyTotals

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

207
internal/handler/burnout.go Normal file
View File

@@ -0,0 +1,207 @@
package handler
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleBurnout(ctx context.Context, msg *models.Message, user *domain.User) {
text := h.buildBurnoutText(user)
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callback --
func (h *Handler) burnoutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildBurnoutText(user)
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Assessment --
func (h *Handler) buildBurnoutText(user *domain.User) string {
res, err := h.assessBurnout(user)
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) / domain.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
if ratio > 100 {
ratio = 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 domain.BurnoutHealthy:
b.WriteString(" Keep up the balanced routine!")
case domain.BurnoutMild:
b.WriteString(" Consider taking short breaks throughout the day.")
b.WriteString("\n Make sure to disconnect after work hours.")
case domain.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 domain.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()
}
func (h *Handler) assessBurnout(user *domain.User) (*domain.BurnoutResult, error) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format(domain.DateLayout)
result := &domain.BurnoutResult{}
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
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
}
todayEvents, _ := h.Repo.EventsForDayByDate(user.ID, today)
if len(todayEvents) > 0 {
day, _ := h.Repo.GetDay(user.ID, today)
if day != nil {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(todayEvents, day.MinBreakThreshold, dayStart, now.Unix())
result.WorkSeconds = totals.TotalSeconds
result.BreakSeconds = totals.BreakSeconds
workHours := float64(totals.TotalSeconds) / domain.SecondsPerHour
switch {
case workHours >= 12:
result.LongHoursPts = 20
case workHours >= 10:
result.LongHoursPts = 15
case workHours >= 8:
result.LongHoursPts = 10
}
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
}
}
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
}
}
}
}
weekday := now.Weekday()
if weekday == time.Saturday || weekday == time.Sunday {
if result.WorkSeconds > 0 {
result.WeekendPts = 15
}
}
result.TrendPts, result.TrendDirection = h.computeTrend(user.ID, today, loc)
result.Score = result.ConsecutivePts + result.LongHoursPts + result.BreakRatioPts +
result.WeekendPts + result.TrendPts + result.LateNightPts
switch {
case result.Score >= 61:
result.Level = domain.BurnoutHigh
case result.Score >= 41:
result.Level = domain.BurnoutModerate
case result.Score >= 21:
result.Level = domain.BurnoutMild
default:
result.Level = domain.BurnoutHealthy
}
return result, nil
}
func (h *Handler) computeTrend(userID int64, today string, loc *time.Location) (int, string) {
t, err := time.Parse(domain.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(domain.DateLayout)
recent[2-i] = h.dayWorkSeconds(userID, d, loc)
}
for i := 0; i < 3; i++ {
d := t.AddDate(0, 0, -(i + 4)).Format(domain.DateLayout)
older[2-i] = h.dayWorkSeconds(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
return domain.ComputeTrendFromAvgs(recentAvg, olderAvg)
}
func (h *Handler) dayWorkSeconds(userID int64, date string, loc *time.Location) int64 {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil || len(events) == 0 {
return 0
}
day, err := h.Repo.GetDay(userID, date)
if err != nil || day == nil {
return 0
}
y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
return totals.TotalSeconds
}

View File

@@ -0,0 +1,542 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- History command and callback --
func (h *Handler) handleHistory(ctx context.Context, msg *models.Message, user *domain.User) {
h.editCalendar(ctx, msg.Chat.ID, 0, 0, 0)
}
func (h *Handler) historyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.editCalendar(ctx, chatID, msgID, 0, 0)
}
// -- Calendar rendering --
func (h *Handler) editCalendar(ctx context.Context, chatID int64, msgID int, year, month int) {
user, err := h.getOrCreateUser(chatID)
if err != nil {
return
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
if year == 0 || month == 0 {
switch user.Calendar {
case "jalali":
jy, jm, _ := domain.GregorianToJalali(now.Year(), int(now.Month()), now.Day())
year, month = jy, jm
case "hijri":
hy, hm, _ := domain.GregorianToHijri(now.Year(), int(now.Month()), now.Day())
year, month = hy, hm
default:
year, month = now.Year(), int(now.Month())
}
}
cal := user.Calendar
cm := domain.BuildCalendarMonth(cal, year, month)
text := cm.Title
todayKey := now.Format(domain.DateLayout)
hasEvent := make(map[string]bool)
startDate, endDate := domain.MonthGregorianRange(cal, year, month)
dates, err := h.Repo.GetDistinctEventDates(user.ID, startDate, endDate)
if err == nil {
for _, d := range dates {
hasEvent[d] = true
}
}
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_year_%d_%d", year-1, month)},
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("cal_show_years_%d_%d", year, month)},
{Text: ">", CallbackData: fmt.Sprintf("cal_next_year_%d_%d", year+1, month)},
},
}
headerRow := []models.InlineKeyboardButton{}
for _, wn := range cm.WeekDays {
headerRow = append(headerRow, models.InlineKeyboardButton{Text: wn, CallbackData: "noop"})
}
kbRows = append(kbRows, headerRow)
row := []models.InlineKeyboardButton{}
for i, d := range cm.Days {
if d.DayNum == 0 {
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
} else {
label := fmt.Sprintf("%d", d.DayNum)
if d.Date == todayKey {
label = fmt.Sprintf("[%d]", d.DayNum)
} else if hasEvent[d.Date] {
label = fmt.Sprintf("%d*", d.DayNum)
}
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: "cal_day_" + d.Date})
}
if len(row) == 7 || i == len(cm.Days)-1 {
for len(row) < 7 {
row = append(row, models.InlineKeyboardButton{Text: " ", CallbackData: "noop"})
}
kbRows = append(kbRows, row)
row = nil
}
}
prevY, prevM, nextY, nextM := domain.NavMonth(year, month)
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "<", CallbackData: fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)},
{Text: "Today", CallbackData: "history"},
{Text: ">", CallbackData: fmt.Sprintf("cal_next_%d_%d", nextY, nextM)},
})
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
}
// -- Calendar callbacks routing --
func (h *Handler) routeHistoryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
loc := h.loadLocation(user.Timezone)
if data == "history" {
h.editCalendar(ctx, chatID, msgID, 0, 0)
return
}
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
}
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_prev_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_next_year_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
if n, _ := fmt.Sscanf(data, "cal_show_years_%d_%d", &y, &m); n == 2 {
h.editCalendarYearPicker(ctx, chatID, msgID, y, m, y)
return
}
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
}
if n, _ := fmt.Sscanf(data, "cal_year_%d", &y); n == 1 {
h.editCalendar(ctx, chatID, msgID, y, 1)
return
}
if n, _ := fmt.Sscanf(data, "cal_years_back_%d_%d", &y, &m); n == 2 {
h.editCalendar(ctx, chatID, msgID, y, m)
return
}
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
}
var eid int64
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
}
var wtid int64
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
h.setEventWorkType(ctx, chatID, msgID, user, eid, wtid, loc)
return
}
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
h.editEventType(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
h.deleteEventPrompt(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
h.deleteEventConfirm(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
h.editEventTime(ctx, chatID, msgID, user, eid, loc)
return
}
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, cbID)
return
}
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
h.editEventTimeMin(ctx, chatID, msgID, user, eid, hh, loc)
return
}
if n, _ := fmt.Sscanf(data, "note_%d", &eid); n == 1 {
h.promptNoteInput(ctx, chatID, msgID, user, eid, loc)
return
}
if n, _ := fmt.Sscanf(data, "note_cancel_%d", &eid); n == 1 {
h.backToDayView(ctx, chatID, msgID, user, eid, loc)
return
}
switch {
case strings.HasPrefix(data, "addin_"):
h.addEventTime(ctx, chatID, msgID, user, data[6:], "in", loc)
case strings.HasPrefix(data, "addout_"):
h.addEventTime(ctx, chatID, msgID, user, data[7:], "out", loc)
case strings.HasPrefix(data, "addtm_"):
h.handleAddEventTime(ctx, chatID, msgID, user, data[6:], loc)
}
}
// -- Calendar year picker --
func (h *Handler) editCalendarYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refM, refY int) {
backData := fmt.Sprintf("cal_years_back_%d_%d", refY, refM)
navSuffix := fmt.Sprintf("_%d_%d", refY, refM)
kb := buildYearPicker("cal_", backData, navSuffix, centerYear)
h.editText(ctx, chatID, msgID, "Select year:", &kb)
}
// -- Day view --
func (h *Handler) editDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location) {
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
func (h *Handler) sendDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, date string, loc *time.Location, isNewMsg bool) {
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err != nil {
if isNewMsg {
h.sendText(ctx, chatID, "Error loading events")
}
return
}
text := fmt.Sprintf("Date: %s", domain.FormatDateForCalendar(date, user.Calendar))
if len(events) == 0 {
text += "\nNo events for this day."
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Add IN", CallbackData: "addin_" + date}},
{{Text: "Add OUT", CallbackData: "addout_" + date}},
{{Text: "Back to Calendar", CallbackData: "history"}},
},
}
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
return
}
text += "\n\nEvents:"
kbRows := [][]models.InlineKeyboardButton{}
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
}
wt := ""
if e.WorkTypeID != nil {
if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil {
wt = " [" + wtObj.Name + "]"
}
}
note := ""
if e.Note != "" {
note = " (" + e.Note + ")"
}
text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note)
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: fmt.Sprintf("Time %s", t), CallbackData: fmt.Sprintf("edit_time_%d", e.ID)},
{Text: "Type", CallbackData: fmt.Sprintf("edit_type_%d", e.ID)},
{Text: "Note", CallbackData: fmt.Sprintf("note_%d", e.ID)},
{Text: "DEL", CallbackData: fmt.Sprintf("delete_%d", e.ID), Style: "danger"},
})
}
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Add IN", CallbackData: "addin_" + date},
{Text: "Add OUT", CallbackData: "addout_" + date},
})
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Calendar", CallbackData: "history"},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(ctx, chatID, msgID, text, &kb)
}
// -- Event editing --
func (h *Handler) editEventType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
wts, err := h.Repo.GetWorkTypes()
if err != nil {
return
}
text := "Select new work type:"
kbRows := [][]models.InlineKeyboardButton{}
for _, wt := range wts {
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: wt.Name, CallbackData: fmt.Sprintf("settype_%d_%d", eventID, wt.ID)},
})
}
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: fmt.Sprintf("back_day_%d", eventID)},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setEventWorkType(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID, workTypeID int64, loc *time.Location) {
if err := h.Repo.UpdateEventWorkType(eventID, workTypeID); err != nil {
slog.Error("failed to update event work type", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, "", loc, false)
return
}
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false)
}
func (h *Handler) editEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
kb := buildHourPickerKeyboard(
func(hh int) string { return fmt.Sprintf("edittm_%d_%02d", eventID, hh) },
fmt.Sprintf("back_day_%d", eventID),
)
h.editText(ctx, chatID, msgID, "Select hour:", &kb)
}
func (h *Handler) editEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh int, loc *time.Location) {
kb := buildMinutePickerKeyboard(
func(mm int) string { return fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm) },
fmt.Sprintf("edit_time_%d", eventID),
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb)
}
func (h *Handler) editEventTimeSet(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, hh, mm int, loc *time.Location, cbID string) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
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 := newTime.Format(domain.DateLayout)
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err == nil {
for _, e := range events {
if e.ID == eventID {
continue
}
eTime := time.Unix(e.OccurredAt, 0)
if newTime.Unix() == eTime.Unix() {
h.answerCb(ctx, cbID)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
}
type eventPos struct {
id int64
typ string
t int64
}
sorted := []eventPos{}
for _, e := range events {
et := e.OccurredAt
if e.ID == eventID {
et = newTime.Unix()
}
sorted = append(sorted, eventPos{e.ID, e.EventType, et})
}
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[j].t < sorted[i].t || (sorted[j].t == sorted[i].t && sorted[j].id < sorted[i].id) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
for i := 1; i < len(sorted); i++ {
if sorted[i].typ == sorted[i-1].typ {
h.editText(ctx, chatID, msgID, "Edit rejected: overlapping events.", nil)
return
}
}
}
if err = h.Repo.UpdateEventTime(eventID, newTime.Unix()); err != nil {
slog.Error("failed to update event time", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
// -- Event deletion --
func (h *Handler) deleteEventPrompt(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Yes, DEL", CallbackData: fmt.Sprintf("delconf_%d", eventID), Style: "danger"},
{Text: "Cancel", CallbackData: fmt.Sprintf("back_day_%d", eventID)},
},
},
}
h.editText(ctx, chatID, msgID, "Delete this event?", &kb)
}
func (h *Handler) deleteEventConfirm(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
date := t.Format(domain.DateLayout)
if err = h.Repo.DeleteEvent(eventID); err != nil {
slog.Error("failed to delete event", "event_id", eventID, "error", err)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
return
}
events, err := h.Repo.EventsForDayByDate(user.ID, date)
if err == nil && len(events) > 1 {
hasIssue := false
for i := 1; i < len(events); i++ {
if events[i].EventType == events[i-1].EventType {
hasIssue = true
break
}
}
if hasIssue {
h.sendText(ctx, chatID, "Warning: Timeline has consecutive events of same type after deletion.")
}
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}
func (h *Handler) backToDayView(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc)
h.sendDayView(ctx, chatID, msgID, user, t.Format(domain.DateLayout), loc, false)
}
// -- Note via pending input --
func (h *Handler) promptNoteInput(ctx context.Context, chatID int64, msgID int, user *domain.User, eventID int64, loc *time.Location) {
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
t := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: fmt.Sprintf("note_cancel_%d", eventID)}},
},
}
h.editText(ctx, chatID, msgID, fmt.Sprintf("Send the note for event at %s:", t), &kb)
h.setPending(ctx, chatID, msgID, user.ID, PendingNote, map[string]string{"event_id": fmt.Sprintf("%d", eventID)})
}
// -- Adding events --
func (h *Handler) addEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, loc *time.Location) {
kb := buildHourPickerKeyboard(
func(hh int) string { return fmt.Sprintf("addtm_%s_%s_%02d", eventType, date, hh) },
"cal_day_"+date,
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select hour for %s event:", eventType), &kb)
}
func (h *Handler) handleAddEventTime(ctx context.Context, chatID int64, msgID int, user *domain.User, payload string, loc *time.Location) {
parts := strings.SplitN(payload, "_", 4)
if len(parts) < 3 {
return
}
eventType := parts[0]
addDate := parts[1]
hh, err := strconv.Atoi(parts[2])
if err != nil {
return
}
if len(parts) == 3 {
h.addEventTimeMin(ctx, chatID, msgID, user, addDate, eventType, hh, loc)
return
}
mm, err := strconv.Atoi(parts[3])
if err != nil {
return
}
h.addEventDo(ctx, chatID, msgID, user, addDate, eventType, hh, mm, loc)
}
func (h *Handler) addEventTimeMin(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh int, loc *time.Location) {
kb := buildMinutePickerKeyboard(
func(mm int) string { return fmt.Sprintf("addtm_%s_%s_%02d_%02d", eventType, date, hh, mm) },
"add"+eventType+"_"+date,
)
h.editText(ctx, chatID, msgID, fmt.Sprintf("Select minute for hour %02d:", hh), &kb)
}
func (h *Handler) addEventDo(ctx context.Context, chatID int64, msgID int, user *domain.User, date, eventType string, hh, mm int, loc *time.Location) {
t, err := time.ParseInLocation("2006-01-02 15:04", date+" "+fmt.Sprintf("%02d:%02d", hh, mm), loc)
if err != nil {
return
}
day, err := h.Repo.GetOrCreateDay(user.ID, date)
if err != nil {
return
}
var wt *int64
if eventType == "in" {
wt = &day.CurrentWorkTypeID
if *wt == 0 {
wt = nil
}
}
if err := h.Repo.CreateEvent(user.ID, day.ID, eventType, wt, t.Unix(), ""); err != nil {
return
}
h.recalcDayWorkSeconds(user.ID, date, loc)
h.recalcAggregates(user.ID, loc)
h.sendDayView(ctx, chatID, msgID, user, date, loc, false)
}

222
internal/handler/clock.go Normal file
View File

@@ -0,0 +1,222 @@
package handler
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Commands --
func (h *Handler) handleClockIn(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doClockIn(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleClockOut(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doClockOut(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleDayOff(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doDayOff(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleReport(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.doReport(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, mainKeyboard())
}
func (h *Handler) handleReportToggle(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
st.ReportEnabled = !st.ReportEnabled
if err := h.Repo.UpdateSettings(st); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving settings")
return
}
status := "disabled"
if st.ReportEnabled {
status = "enabled"
}
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Daily report %s", status))
}
// -- Callbacks --
func (h *Handler) clockInCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doClockIn(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) clockOutCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doClockOut(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) dayOffCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doDayOff(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) reportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.doReport(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Business logic (shared between commands and callbacks) --
func (h *Handler) doClockIn(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
last, err := h.Repo.GetLastEvent(user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", err
}
if last != nil && last.EventType == "in" {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
if lastTime.Format(domain.DateLayout) == now.Format(domain.DateLayout) {
return "", errors.New("already clocked in")
}
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
wt := day.CurrentWorkTypeID
if err := h.Repo.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
return "", err
}
return fmt.Sprintf("Clocked in at %s", now.Format(domain.TimeLayout)), nil
}
func (h *Handler) doClockOut(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
last, err := h.Repo.GetLastEvent(user.ID)
if err != nil {
return "", err
}
if last == nil {
return "", errors.New("no clock-in found - start with /clockin first")
}
if last.EventType == "out" {
return "", errors.New("already clocked out")
}
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
if err := h.Repo.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
return "", err
}
text := fmt.Sprintf("Clocked out at %s", now.Format(domain.TimeLayout))
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err == nil && st.RPGEnabled {
sessionWork := now.Unix() - last.OccurredAt
today := now.Format(domain.DateLayout)
isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err == nil {
text += h.awardXPAndCheckAchievements(user, stats, sessionWork, today, isWeekend)
}
}
return text, nil
}
func (h *Handler) doDayOff(user *domain.User) (string, error) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return "", err
}
if day.IsDayOff {
if err := h.Repo.RemoveDayOff(user.ID, day.Date); err != nil {
return "", err
}
return "Day off removed", nil
}
if err := h.Repo.SetDayOff(user.ID, day.Date, ""); err != nil {
return "", err
}
return "Today marked as day off", nil
}
func (h *Handler) doReport(user *domain.User) (string, error) {
day, err := h.getTodayDay(user)
if err != nil {
return "", err
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
return "", err
}
return h.buildDailyReport(user, day, events), nil
}
func (h *Handler) getTodayDay(user *domain.User) (*domain.Day, error) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
return h.Repo.GetOrCreateDay(user.ID, today)
}
func (h *Handler) getTodayBreakThreshold(user *domain.User) int64 {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetDay(user.ID, today)
if err != nil {
return domain.DefaultBreakThreshold
}
return day.MinBreakThreshold
}

531
internal/handler/export.go Normal file
View File

@@ -0,0 +1,531 @@
package handler
import (
"bytes"
"context"
"fmt"
"log/slog"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/xuri/excelize/v2"
"worktimeBot/internal/domain"
)
type themeColors struct {
headerFill string
border string
totalFill string
}
var themes = map[string]themeColors{
"ocean": {headerFill: "4472C4", border: "D9D9D9", totalFill: "D9E2F3"},
"beach": {headerFill: "C87D3C", border: "E8D5B7", totalFill: "FFF0E0"},
"rose": {headerFill: "B86C80", border: "F0D0E0", totalFill: "FCE8F0"},
"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 (h *Handler) generateMonthlyReport(user *domain.User, calYear, calMonth int, start, end time.Time) ([]byte, error) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return nil, err
}
accent := st.ExportAccent
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
}
f := excelize.NewFile()
defer f.Close()
sheet := "Report"
index, err := f.NewSheet(sheet)
if err != nil {
return nil, err
}
f.SetActiveSheet(index)
f.DeleteSheet("Sheet1")
styles := newReportStyles(f, theme)
monthName := domain.FormatMonthTitle(user.Calendar, calYear, calMonth)
h.writeReportHeader(f, sheet, fmt.Sprintf("Work Time Report - %s", monthName), styles)
loc := h.loadLocation(user.Timezone)
row := 3
userDays, err := h.Repo.GetUserDaysInRange(user.ID, start.Format(domain.DateLayout), end.Format(domain.DateLayout))
if err != nil {
return nil, err
}
dayMap := make(map[string]*domain.Day)
for i := range userDays {
dayMap[userDays[i].Date] = &userDays[i]
}
var totalWork, totalBreak int64
layout := start.Format(domain.DateLayout)
endLayout := end.Format(domain.DateLayout)
cur := start
for !cur.After(end) {
dateStr := cur.Format(domain.DateLayout)
displayDate := domain.FormatDateForCalendar(dateStr, user.Calendar)
var (
day *domain.Day
events []domain.Event
hasDayOff bool
)
if existing, ok := dayMap[dateStr]; ok {
day = existing
events, err = h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
return nil, err
}
hasDayOff = day.IsDayOff
}
var typeLabel string
if hasDayOff {
if day.DayOffReason != "" {
typeLabel = "Day Off (" + day.DayOffReason + ")"
} else {
typeLabel = "Day Off"
}
} else if len(events) > 0 {
typeLabel = h.workTypeLabel(day, events)
}
ws, bs := h.writeReportDayRow(f, sheet, row, displayDate, typeLabel, events, day, loc, hasDayOff, styles)
totalWork += ws
totalBreak += bs
row++
cur = cur.AddDate(0, 0, 1)
}
_ = layout
_ = endLayout
h.writeReportTotalRow(f, sheet, row, totalWork, totalBreak, styles)
buf, err := f.WriteToBuffer()
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (h *Handler) 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, hdr := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, hdr)
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 (h *Handler) writeReportDayRow(f *excelize.File, sheet string, row int, displayDate, typeLabel string, events []domain.Event, day *domain.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(domain.TimeLayout))
f.SetCellStyle(sheet, cellIn, cellIn, s.data)
}
if len(events) > 1 {
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(domain.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 {
d, err := time.Parse(domain.DateLayout, day.Date)
if err == 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 := domain.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 (h *Handler) 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 fmtHHMM(seconds int64) string {
hours := seconds / domain.SecondsPerHour
mins := (seconds % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d", hours, mins)
}
func fmtDDHHMM(seconds int64) string {
days := seconds / domain.SecondsPerDay
rem := seconds % domain.SecondsPerDay
hours := rem / domain.SecondsPerHour
mins := (rem % domain.SecondsPerHour) / 60
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
}
func (h *Handler) workTypeLabel(day *domain.Day, events []domain.Event) string {
wtSeen := make(map[int64]bool)
for _, e := range events {
if e.WorkTypeID != nil {
wtSeen[*e.WorkTypeID] = true
}
}
if len(wtSeen) == 0 {
wt, err := h.Repo.GetWorkType(day.CurrentWorkTypeID)
if err == nil {
return wt.Name
}
return ""
}
if len(wtSeen) == 1 {
for id := range wtSeen {
wt, err := h.Repo.GetWorkType(id)
if err == nil {
return wt.Name
}
}
}
return "mixed"
}
// -- Commands --
func (h *Handler) handleExport(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
args := strings.Fields(msg.Text)
if len(args) >= 1 && args[0][0] == '/' {
args = args[1:]
}
if len(args) >= 1 {
if n, _ := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); n == 2 {
if calY < 0 || calM < 1 || calM > 12 {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
return
}
}
}
h.sendExportDirect(ctx, msg.Chat.ID, user, calY, calM)
}
func (h *Handler) sendExportDirect(ctx context.Context, chatID int64, user *domain.User, calYear, calMonth int) {
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
h.sendText(ctx, chatID, "You are currently clocked in. Please clock out first, then try again.")
return
}
loc := h.loadLocation(user.Timezone)
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
}
end, err := time.Parse(domain.DateLayout, endStr)
if err != nil {
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
end = startOfMonth.AddDate(0, 1, -1)
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
data, err := h.generateMonthlyReport(user, calYear, calMonth, start, end)
if err != nil {
slog.Error("generate report", "error", err)
h.sendText(ctx, chatID, "Error generating report")
return
}
slog.Info("report generated", "bytes", len(data))
if _, err := h.Bot.SendDocument(ctx, &tgbot.SendDocumentParams{
ChatID: chatID,
Document: &models.InputFileUpload{
Filename: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
Data: bytes.NewReader(data),
},
}); err != nil {
slog.Error("send document", "error", err)
}
}
// -- Callbacks --
func (h *Handler) exportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
cy, cm, _ = domain.GregorianToJalali(cy, cm, now.Day())
case "hijri":
cy, cm, _ = domain.GregorianToHijri(cy, cm, now.Day())
}
h.editExportMonthPicker(ctx, chatID, msgID, cy, cm, user)
}
func (h *Handler) editExportMonthPicker(ctx context.Context, chatID int64, msgID int, year, _ int, user *domain.User) {
months := domain.GregMonthNames
switch user.Calendar {
case "jalali":
months = domain.JalaliMonthNames
case "hijri":
months = domain.HijriMonthNames
}
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("exp_year_prev_%d", year)},
{Text: fmt.Sprintf("%d", year), CallbackData: fmt.Sprintf("exp_show_years_%d", year)},
{Text: ">", CallbackData: fmt.Sprintf("exp_year_next_%d", year)},
},
}
for i := 0; i < 12; i += 4 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+4 && j < 12; j++ {
row = append(row, models.InlineKeyboardButton{
Text: months[j], CallbackData: fmt.Sprintf("exp_do_%d_%d", year, j+1),
})
}
kbRows = append(kbRows, row)
}
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
kb := models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
h.sendOrEdit(ctx, chatID, msgID, "Select month to export:", &kb)
}
func (h *Handler) handleExportCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
defer h.answerCb(ctx, cbID)
if h.handleExportNav(ctx, chatID, msgID, user, data) {
return
}
h.handleExportDo(ctx, chatID, msgID, user, data)
}
func (h *Handler) handleExportNav(ctx context.Context, chatID int64, msgID int, user *domain.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 true
}
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y+1, 0, user)
return true
}
if n, _ := fmt.Sscanf(data, "exp_show_years_%d", &y); n == 1 {
h.editExportYearPicker(ctx, chatID, msgID, y, y)
return true
}
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 true
}
if n, _ := fmt.Sscanf(data, "exp_year_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
return true
}
if n, _ := fmt.Sscanf(data, "exp_years_back_%d", &y); n == 1 {
h.editExportMonthPicker(ctx, chatID, msgID, y, 0, user)
return true
}
return false
}
func (h *Handler) handleExportDo(ctx context.Context, chatID int64, msgID int, user *domain.User, data string) {
var y, m int
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n != 2 {
return
}
last, err := h.Repo.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, "You are currently clocked in. Please clock out first, then try again.", &backKB)
return
}
startStr, endStr := domain.MonthGregorianRange(user.Calendar, y, m)
start, err := time.Parse(domain.DateLayout, startStr)
if err != nil {
h.sendText(ctx, chatID, "Error processing date")
return
}
end, err := time.Parse(domain.DateLayout, 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)
reportData, err := h.generateMonthlyReport(user, 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"}},
},
}
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, &tgbot.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)
}
}
func (h *Handler) editExportYearPicker(ctx context.Context, chatID int64, msgID int, centerYear, refY int) {
backData := fmt.Sprintf("exp_years_back_%d", refY)
navSuffix := fmt.Sprintf("_%d", refY)
kb := buildYearPicker("exp_", backData, navSuffix, centerYear)
h.editText(ctx, chatID, msgID, "Select year:", &kb)
}

453
internal/handler/handler.go Normal file
View File

@@ -0,0 +1,453 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
"worktimeBot/internal/repo"
)
const rateLimitInterval = 1 * time.Second
type PendingKind string
const (
PendingNote PendingKind = "note"
PendingRate PendingKind = "rate"
PendingCurrency PendingKind = "currency"
PendingTimezone PendingKind = "timezone"
PendingBreak PendingKind = "break_threshold"
PendingUsername PendingKind = "username"
PendingReportTime PendingKind = "report_time"
)
type PendingInput struct {
Kind PendingKind
UserID int64
CreatedAt time.Time
MsgID int
Data map[string]string
}
type Handler struct {
Bot *tgbot.Bot
Repo repo.Repository
AllowedUsers map[int64]bool
lastMsgTime map[int64]time.Time
pendingInput map[int64]*PendingInput
mu sync.Mutex
}
func NewHandler(bot *tgbot.Bot, store repo.Repository, allowed map[int64]bool) *Handler {
h := &Handler{
Bot: bot,
Repo: store,
AllowedUsers: allowed,
lastMsgTime: make(map[int64]time.Time),
pendingInput: make(map[int64]*PendingInput),
}
go h.periodicCleanup()
return h
}
func (h *Handler) periodicCleanup() {
for {
time.Sleep(10 * time.Second)
h.mu.Lock()
cutoff := time.Now().Add(-rateLimitInterval * 2)
for uid, t := range h.lastMsgTime {
if t.Before(cutoff) {
delete(h.lastMsgTime, uid)
}
}
pendingCutoff := time.Now().Add(-5 * time.Minute)
for chatID, pi := range h.pendingInput {
if pi.CreatedAt.Before(pendingCutoff) {
delete(h.pendingInput, chatID)
}
}
h.mu.Unlock()
}
}
func (h *Handler) isRateLimited(userID int64) bool {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval {
return true
}
h.lastMsgTime[userID] = now
return false
}
func (h *Handler) getOrCreateUser(chatID int64) (*domain.User, error) {
return h.Repo.GetOrCreateUser(chatID)
}
func (h *Handler) loadLocation(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}
func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) {
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] {
return
}
if msg.Text == "" {
return
}
user, err := h.getOrCreateUser(msg.Chat.ID)
if err != nil {
return
}
if h.isRateLimited(user.ID) {
return
}
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text)
if msg.Text[0] == '/' {
h.mu.Lock()
delete(h.pendingInput, msg.Chat.ID)
h.mu.Unlock()
h.routeCommand(ctx, msg, user)
return
}
h.mu.Lock()
pi, hasPI := h.pendingInput[msg.Chat.ID]
if hasPI {
delete(h.pendingInput, msg.Chat.ID)
}
h.mu.Unlock()
if hasPI {
h.processPendingInput(ctx, msg, user, pi)
return
}
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *domain.User) {
cmd := msg.Text
if i := strings.IndexByte(msg.Text, ' '); i >= 0 {
cmd = msg.Text[:i]
}
switch cmd {
case "/start":
h.handleStart(ctx, msg, user)
case "/help":
h.handleHelp(ctx, msg)
case "/clockin":
h.handleClockIn(ctx, msg, user)
case "/clockout":
h.handleClockOut(ctx, msg, user)
case "/worktype":
h.handleWorkType(ctx, msg, user)
case "/report":
h.handleReport(ctx, msg, user)
case "/export":
h.handleExport(ctx, msg, user)
case "/dayoff":
h.handleDayOff(ctx, msg, user)
case "/history":
h.handleHistory(ctx, msg, user)
case "/edit":
h.handleEdit(ctx, msg, user)
case "/reporttoggle":
h.handleReportToggle(ctx, msg, user)
case "/setreporttime":
h.handleSetReportTime(ctx, msg, user)
case "/setaccent":
h.handleSetAccent(ctx, msg, user)
case "/settimezone":
h.handleSetTimezone(ctx, msg, user)
case "/note":
h.handleNote(ctx, msg, user)
case "/summary":
h.handleSummary(ctx, msg, user)
case "/setbreak":
h.handleSetBreak(ctx, msg, user)
case "/rpg":
h.handleRPG(ctx, msg, user)
case "/achievements":
h.handleAchievements(ctx, msg, user)
case "/salary":
h.handleSalary(ctx, msg, user)
case "/burnout":
h.handleBurnout(ctx, msg, user)
case "/league":
h.handleLeague(ctx, msg, user)
case "/setrate":
h.handleSetRate(ctx, msg, user)
case "/setcurrency":
h.handleSetCurrency(ctx, msg, user)
case "/rpgtoggle":
h.handleRPGSettings(ctx, msg, user)
case "/leaguetoggle":
h.handleLeagueToggle(ctx, msg, user)
case "/setusername":
h.handleSetUsername(ctx, msg, user)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown command")
}
}
func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) {
chatID := cb.Message.Message.Chat.ID
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[chatID] {
h.answerCbWithText(ctx, cb.ID, "Unauthorized")
return
}
user, err := h.getOrCreateUser(chatID)
if err != nil {
h.answerCb(ctx, cb.ID)
return
}
if h.isRateLimited(user.ID) {
h.answerCb(ctx, cb.ID)
return
}
slog.Info("callback", "chat_id", chatID, "data", cb.Data)
h.mu.Lock()
delete(h.pendingInput, chatID)
h.mu.Unlock()
msgID := cb.Message.Message.ID
data := cb.Data
switch {
case data == "pending_cancel":
h.backToMenu(ctx, chatID, msgID, cb.ID)
case data == "clockin":
h.clockInCallback(ctx, chatID, msgID, cb.ID, user)
case data == "clockout":
h.clockOutCallback(ctx, chatID, msgID, cb.ID, user)
case data == "dayoff":
h.dayOffCallback(ctx, chatID, msgID, cb.ID, user)
case data == "report":
h.reportCallback(ctx, chatID, msgID, cb.ID, user)
case data == "worktype":
h.workTypeCallback(ctx, chatID, msgID, cb.ID, user)
case data == "export":
h.exportCallback(ctx, chatID, msgID, cb.ID, user)
case data == "settings":
h.settingsCallback(ctx, chatID, msgID, cb.ID, user)
case data == "history":
h.historyCallback(ctx, chatID, msgID, cb.ID, user)
case data == "noop":
h.answerCb(ctx, cb.ID)
case data == "rpg":
h.rpgCallback(ctx, chatID, msgID, cb.ID, user)
case data == "achievements":
h.achievementsCallback(ctx, chatID, msgID, cb.ID, user)
case data == "salary":
h.salaryCallback(ctx, chatID, msgID, cb.ID, user)
case data == "burnout":
h.burnoutCallback(ctx, chatID, msgID, cb.ID, user)
case data == "league":
h.leagueCallback(ctx, chatID, msgID, cb.ID, user, string(domain.LeagueSortXP))
case data == "back_menu":
h.backToMenu(ctx, chatID, msgID, cb.ID)
default:
h.routePrefixedCallback(ctx, chatID, msgID, cb.ID, user, data)
}
}
func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) {
switch {
case strings.HasPrefix(data, "worktype_"):
h.selectWorkType(ctx, chatID, msgID, cbID, user, data[9:])
case strings.HasPrefix(data, "accent_"):
h.selectAccent(ctx, chatID, msgID, cbID, user, data[7:])
case strings.HasPrefix(data, "caltype_"):
h.selectCalendar(ctx, chatID, msgID, cbID, user, data[8:])
case strings.HasPrefix(data, "exp_"):
h.handleExportCallback(ctx, chatID, msgID, cbID, user, data)
case strings.HasPrefix(data, "breakthreshold_"):
h.selectBreakThreshold(ctx, chatID, msgID, cbID, user, data[15:])
case strings.HasPrefix(data, "currency_"):
h.selectCurrency(ctx, chatID, msgID, cbID, user, data[9:])
case data == "rpg_enable":
h.setRPGCallback(ctx, chatID, msgID, cbID, user, true)
case data == "rpg_disable":
h.setRPGCallback(ctx, chatID, msgID, cbID, user, false)
case data == "league_enable":
h.setLeagueCallback(ctx, chatID, msgID, cbID, user, true)
case data == "league_disable":
h.setLeagueCallback(ctx, chatID, msgID, cbID, user, false)
case data == "report_enable":
h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, true)
case data == "report_disable":
h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, false)
case data == "back_settings":
h.backToSettings(ctx, chatID, msgID, cbID, user)
case data == "rpgtoggle":
h.rpgToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "leaguetoggle":
h.leagueToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "reporttoggle":
h.reportToggleCallback(ctx, chatID, msgID, cbID, user)
case data == "rpg_achievements":
h.rpgAchievementsCallback(ctx, chatID, msgID, cbID, user)
case data == "rpg_back":
h.rpgBackCallback(ctx, chatID, msgID, cbID, user)
case data == "username":
h.usernameCallback(ctx, chatID, msgID, cbID, user)
case data == "currency":
h.currencyCallback(ctx, chatID, msgID, cbID, user)
case data == "setrate":
h.rateCallback(ctx, chatID, msgID, cbID, user)
case data == "timezone":
h.timezoneCallback(ctx, chatID, msgID, cbID, user)
case data == "caltype":
h.calTypeCallback(ctx, chatID, msgID, cbID, user)
case data == "accent":
h.accentCallback(ctx, chatID, msgID, cbID, user)
case data == "reporttime":
h.reportTimeCallback(ctx, chatID, msgID, cbID, user)
case data == "breakthreshold":
h.breakThresholdCallback(ctx, chatID, msgID, cbID, user)
case strings.HasPrefix(data, "league_"):
sortBy := data[7:]
h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy)
default:
h.routeHistoryCallback(ctx, chatID, msgID, cbID, user, data)
}
}
func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) {
h.mu.Lock()
h.pendingInput[chatID] = &PendingInput{
Kind: kind,
UserID: userID,
CreatedAt: time.Now(),
MsgID: msgID,
Data: data,
}
h.mu.Unlock()
}
func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput) {
text := strings.TrimSpace(msg.Text)
switch pi.Kind {
case PendingNote:
h.processNoteInput(ctx, msg, user, pi, text)
case PendingRate:
h.processRateInput(ctx, msg, user, pi, text)
case PendingCurrency:
h.processCurrencyInput(ctx, msg, user, pi, text)
case PendingTimezone:
h.processTimezoneInput(ctx, msg, user, pi, text)
case PendingBreak:
h.processBreakInput(ctx, msg, user, pi, text)
case PendingUsername:
h.processUsernameInput(ctx, msg, user, pi, text)
case PendingReportTime:
h.processReportTimeInput(ctx, msg, user, pi, text)
default:
h.sendText(ctx, msg.Chat.ID, "Unknown input type")
}
}
func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) {
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Cancel", CallbackData: "pending_cancel"}},
},
}
if msgID == 0 {
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: chatID, Text: prompt, ReplyMarkup: kb,
})
if err != nil {
slog.Warn("send prompt failed", "chat_id", chatID, "error", err)
return
}
h.setPending(ctx, chatID, msg.ID, userID, kind, data)
} else {
h.editText(ctx, chatID, msgID, prompt, &kb)
h.setPending(ctx, chatID, msgID, userID, kind, data)
}
}
// sendOrEdit sends a new message or edits an existing one depending on msgID.
func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
if msgID == 0 {
if kb != nil {
h.sendWithKB(ctx, chatID, text, *kb)
} else {
h.sendText(ctx, chatID, text)
}
} else {
h.editText(ctx, chatID, msgID, text, kb)
}
}
// -- helpers --
func (h *Handler) sendText(ctx context.Context, chatID int64, text string) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("send text failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) {
p := &tgbot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text}
if kb != nil {
p.ReplyMarkup = kb
}
if _, err := h.Bot.EditMessageText(ctx, p); err != nil {
slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err)
}
}
func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) {
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil {
slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err)
}
}
func (h *Handler) answerCb(ctx context.Context, callbackID string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil {
slog.Debug("answer callback failed", "error", err)
}
}
func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) {
if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil {
slog.Debug("answer callback with text failed", "error", err)
}
}
func (h *Handler) tryUnlockAchievement(userID int64, code string) {
a, err := h.Repo.GetAchievementByCode(code)
if err != nil {
return
}
if _, err := h.Repo.UnlockAchievement(userID, a.ID); err != nil {
slog.Error("unlock achievement", "code", code, "error", err)
}
}
func formatDuration(seconds int64) string {
hours := seconds / domain.SecondsPerHour
mins := (seconds % domain.SecondsPerHour) / 60
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}

View File

@@ -0,0 +1,111 @@
package handler
import (
"fmt"
"github.com/go-telegram/bot/models"
)
func mainKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{
{Text: "Clock In", CallbackData: "clockin"},
{Text: "Clock Out", CallbackData: "clockout"},
},
{
{Text: "Work Type", CallbackData: "worktype"},
{Text: "Day Off", CallbackData: "dayoff"},
},
{
{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"},
},
},
}
}
func backKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
}
func settingsBackKeyboard() models.InlineKeyboardMarkup {
return models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Settings", CallbackData: "back_settings"}},
},
}
}
// buildYearPicker returns a 12-year grid keyboard centered on centerYear.
func buildYearPicker(cbPrefix, backData, navSuffix string, centerYear int) models.InlineKeyboardMarkup {
blockStart := centerYear - 6
kbRows := [][]models.InlineKeyboardButton{
{
{Text: "<", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart-12, navSuffix)},
{Text: fmt.Sprintf("%d", centerYear), CallbackData: "noop"},
{Text: ">", CallbackData: fmt.Sprintf("%syears_%d%s", cbPrefix, blockStart+12, navSuffix)},
},
}
for i := 0; i < 12; i += 4 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+4; j++ {
y := blockStart + j
row = append(row, models.InlineKeyboardButton{
Text: fmt.Sprintf("%d", y), CallbackData: fmt.Sprintf("%syear_%d", cbPrefix, y),
})
}
kbRows = append(kbRows, row)
}
if backData != "" {
kbRows = append(kbRows, []models.InlineKeyboardButton{
{Text: "Back", CallbackData: backData},
})
}
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// buildHourPickerKeyboard creates the 24-hour grid for time selection.
func buildHourPickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
hourRow := []models.InlineKeyboardButton{}
for hh := 0; hh < 24; hh++ {
hourRow = append(hourRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", hh), CallbackData: cb(hh),
})
if len(hourRow) == 6 || hh == 23 {
kbRows = append(kbRows, hourRow)
hourRow = nil
}
}
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}
// buildMinutePickerKeyboard creates the 15-min interval row for minute selection.
func buildMinutePickerKeyboard(cb func(int) string, backData string) models.InlineKeyboardMarkup {
kbRows := [][]models.InlineKeyboardButton{}
minRow := []models.InlineKeyboardButton{}
for _, mm := range []int{0, 10, 20, 30, 40, 50} {
minRow = append(minRow, models.InlineKeyboardButton{
Text: fmt.Sprintf("%02d", mm), CallbackData: cb(mm),
})
}
kbRows = append(kbRows, minRow)
kbRows = append(kbRows, []models.InlineKeyboardButton{{Text: "Back", CallbackData: backData}})
return models.InlineKeyboardMarkup{InlineKeyboard: kbRows}
}

111
internal/handler/league.go Normal file
View File

@@ -0,0 +1,111 @@
package handler
import (
"context"
"fmt"
"strings"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleLeague(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.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(domain.LeagueSortXP)
kb := h.buildLeagueKeyboard(string(domain.LeagueSortXP))
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// -- Callback --
func (h *Handler) leagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, orderBy string) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.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(domain.LeagueSortOption(orderBy))
kb := h.buildLeagueKeyboard(orderBy)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Builders --
func (h *Handler) buildLeagueText(orderBy domain.LeagueSortOption) string {
entries, err := h.Repo.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": "Hours"}
b.WriteString(fmt.Sprintf("WorkTime League - %s\n\n", orderLabel[string(orderBy)]))
for i, e := range entries {
rank := i + 1
name := e.Username
if name == "" {
name = "Anonymous"
}
b.WriteString(fmt.Sprintf("%d %s\n", rank, name))
b.WriteString(fmt.Sprintf(" XP:%d Lv:%d S:%d %.1fh\n", e.XP, e.Level, e.Streak, e.TotalHours))
}
b.WriteString(fmt.Sprintf("\n%d participant(s)\n", len(entries)))
return b.String()
}
func (h *Handler) buildLeagueRankText(userID int64) string {
entries, err := h.Repo.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 ""
}
func (h *Handler) buildLeagueKeyboard(currentOrder string) models.InlineKeyboardMarkup {
type sortBtn struct{ label, data string }
orders := []sortBtn{
{"XP", "league_xp"},
{"Level", "league_level"},
{"Streak", "league_streak"},
{"Hours", "league_hours"},
}
rows := [][]models.InlineKeyboardButton{}
for i := 0; i < len(orders); i += 2 {
row := []models.InlineKeyboardButton{}
for j := i; j < i+2 && j < len(orders); j++ {
label := orders[j].label
if orders[j].data == "league_"+currentOrder {
label = "> " + label
}
row = append(row, models.InlineKeyboardButton{Text: label, CallbackData: orders[j].data})
}
rows = append(rows, row)
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
return models.InlineKeyboardMarkup{InlineKeyboard: rows}
}

91
internal/handler/note.go Normal file
View File

@@ -0,0 +1,91 @@
package handler
import (
"context"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// handleNote handles /note [YYYY-MM-DD] HH:MM <text> command.
func (h *Handler) handleNote(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
parts := strings.Fields(msg.Text)
if len(parts) < 3 {
h.promptForNote(ctx, msg, user)
return
}
var dateStr string
var timeStr string
var noteParts []string
if len(parts[1]) == 10 && parts[1][4] == '-' && parts[1][7] == '-' {
dateStr = parts[1]
timeStr = parts[2]
noteParts = parts[3:]
} else {
dateStr = time.Now().In(loc).Format(domain.DateLayout)
timeStr = parts[1]
noteParts = parts[2:]
}
if len(noteParts) == 0 {
h.promptForNote(ctx, msg, user)
return
}
events, err := h.Repo.EventsForDayByDate(user.ID, dateStr)
if err != nil || len(events) == 0 {
h.sendText(ctx, msg.Chat.ID, "No events found for that day")
return
}
var targetEvent *domain.Event
for i := range events {
t := time.Unix(events[i].OccurredAt, 0).In(loc).Format(domain.TimeLayout)
if t == timeStr {
targetEvent = &events[i]
break
}
}
if targetEvent == nil {
h.sendText(ctx, msg.Chat.ID, "No event found at that time")
return
}
note := domain.SanitizeNote(strings.Join(noteParts, " "))
if err := h.Repo.SetEventNote(targetEvent.ID, note); err != nil {
h.sendText(ctx, msg.Chat.ID, "Error saving note")
return
}
h.sendText(ctx, msg.Chat.ID, "Note saved")
}
func (h *Handler) promptForNote(ctx context.Context, msg *models.Message, user *domain.User) {
h.sendText(ctx, msg.Chat.ID, "Usage: /note [YYYY-MM-DD] HH:MM <text>\nUse the History menu to add notes visually.")
}
// handleEdit handles /edit YYYY-MM-DD command.
func (h *Handler) handleEdit(ctx context.Context, msg *models.Message, user *domain.User) {
date := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/edit"))
if date == "" {
h.sendText(ctx, msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)")
return
}
if len(date) != 10 || date[4] != '-' || date[7] != '-' {
h.sendText(ctx, msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)")
return
}
_, m, d := domain.ParseGregorianDateKey(date)
if m < 1 || m > 12 || d < 1 || d > 31 {
h.sendText(ctx, msg.Chat.ID, "Invalid date.")
return
}
loc := h.loadLocation(user.Timezone)
date = domain.UserDateToGregorian(date, user.Calendar)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}

169
internal/handler/report.go Normal file
View File

@@ -0,0 +1,169 @@
package handler
import (
"context"
"fmt"
"log/slog"
"time"
tgbot "github.com/go-telegram/bot"
"worktimeBot/internal/domain"
)
func (h *Handler) buildStatusText(user *domain.User) string {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
today := now.Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return "Welcome. Choose an action:"
}
text := fmt.Sprintf("Today: %s", domain.FormatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
text += "\nDay Off"
}
wt, _ := h.Repo.GetWorkType(day.CurrentWorkTypeID)
if wt != nil {
text += fmt.Sprintf("\nWork type: %s", wt.Name)
}
last, err := h.Repo.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
t := time.Unix(last.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
text += fmt.Sprintf("\nStatus: working since %s", t)
} else {
text += "\nStatus: not working"
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err == nil && len(events) > 0 {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix())
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
}
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st != nil && st.HourlyRate > 0 {
if events, err := h.Repo.EventsForDayByDayID(day.ID); err == nil && len(events) > 0 {
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, now.Unix())
sal := h.buildSalaryStatus(user, totals.TotalSeconds)
if sal != "" {
text += sal
}
}
}
if st != nil && st.RPGEnabled {
stats, _ := h.Repo.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 += "\n " + rankText
}
}
}
text += "\n\nChoose an action:"
return text
}
func (h *Handler) buildDailyReport(user *domain.User, day *domain.Day, events []domain.Event) string {
loc := h.loadLocation(user.Timezone)
if len(events) == 0 {
if day.IsDayOff {
return fmt.Sprintf("%s - Day Off", domain.FormatDateForCalendar(day.Date, user.Calendar))
}
return fmt.Sprintf("%s - No events recorded.", domain.FormatDateForCalendar(day.Date, user.Calendar))
}
y, m, d := domain.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(domain.DateLayout)
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
if day.Date == todayStr {
dayEnd = now.Unix()
}
totals := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
lines := fmt.Sprintf("Report for %s", domain.FormatDateForCalendar(day.Date, user.Calendar))
if day.IsDayOff {
lines += "\nDay Off: Yes"
}
lines += "\n\n"
for _, e := range events {
t := time.Unix(e.OccurredAt, 0).In(loc).Format(domain.TimeLayout)
label := "IN"
if e.EventType == "out" {
label = "OUT"
}
suffix := ""
if e.WorkTypeID != nil {
if wtObj, err := h.Repo.GetWorkType(*e.WorkTypeID); err == nil {
suffix = " [" + wtObj.Name + "]"
}
}
if e.Note != "" {
suffix += " (" + e.Note + ")"
}
lines += fmt.Sprintf("%s %s%s\n", label, t, suffix)
}
lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n",
formatDuration(totals.TotalSeconds), formatDuration(totals.BreakSeconds))
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st != nil && st.HourlyRate > 0 {
est := domain.ComputeDailySalary(totals.TotalSeconds, st.HourlyRate)
s := domain.SalaryEstimate{WorkSeconds: totals.TotalSeconds, GrossEarning: est}
lines += fmt.Sprintf(" Salary: %s\n", s.FormatSalaryShort(st.Currency))
}
return lines
}
func (h *Handler) buildSalaryStatus(user *domain.User, workSeconds int64) string {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil || st.HourlyRate <= 0 {
return ""
}
est := domain.ComputeDailySalary(workSeconds, st.HourlyRate)
s := domain.SalaryEstimate{WorkSeconds: workSeconds, GrossEarning: est, HourlyRate: st.HourlyRate}
return fmt.Sprintf("\nSalary: %s (%.2f/hr)", s.FormatSalaryShort(st.Currency), st.HourlyRate)
}
func (h *Handler) backToMenu(ctx context.Context, chatID int64, msgID int, cbID string) {
defer h.answerCb(ctx, cbID)
user, err := h.getOrCreateUser(chatID)
if err != nil {
h.editText(ctx, chatID, msgID, "Error loading profile", nil)
return
}
kb := mainKeyboard()
h.editText(ctx, chatID, msgID, h.buildStatusText(user), &kb)
}
// SendDailyReport sends a daily report to a specific user (called by scheduler).
func (h *Handler) SendDailyReport(ctx context.Context, userID int64, chatID int64) {
user, err := h.Repo.GetOrCreateUser(chatID)
if err != nil {
slog.Error("SendDailyReport: get user", "chat_id", chatID, "error", err)
return
}
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
slog.Error("SendDailyReport: get day", "chat_id", chatID, "error", err)
return
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil {
slog.Error("SendDailyReport: events", "chat_id", chatID, "error", err)
return
}
text := h.buildDailyReport(user, day, events)
if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil {
slog.Warn("SendDailyReport: send", "chat_id", chatID, "error", err)
}
}

336
internal/handler/rpg.go Normal file
View File

@@ -0,0 +1,336 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Commands --
func (h *Handler) handleRPG(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.")
return
}
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleAchievements(ctx context.Context, msg *models.Message, user *domain.User) {
text := h.buildAchievementsList(user)
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callbacks --
func (h *Handler) rpgCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) achievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildAchievementsList(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildAchievementsList(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Back to RPG", CallbackData: "rpg_back"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text := h.buildRPGStatus(user)
kb := models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
},
}
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- RPG business logic --
func (h *Handler) buildRPGStatus(user *domain.User) string {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil || !st.RPGEnabled {
return "RPG system is disabled."
}
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
if err != nil {
return "RPG stats unavailable"
}
nextLevelXP := domain.XpForLevel(stats.Level + 1)
currentLevelXP := domain.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) / domain.SecondsPerHour
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
achs, _ := h.Repo.GetUserAchievements(user.ID)
if len(achs) > 0 {
b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs)))
}
return b.String()
}
func (h *Handler) buildAchievementsList(user *domain.User) string {
achs, err := h.Repo.GetUserAchievements(user.ID)
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(domain.DateLayout)))
}
return b.String()
}
func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string {
text := ""
h.updateStreak(stats, user.ID, today)
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
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
}
func (h *Handler) computeAndAwardXP(stats *domain.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, _ error) {
baseXP := domain.XpForWorkSeconds(workSeconds)
bonus := domain.StreakBonus(streak)
totalXP := baseXP + bonus
if totalXP < 0 {
totalXP = 0
}
stats.XP += totalXP
newLevel = domain.LevelForXP(stats.XP)
leveledUp = newLevel > stats.Level
stats.Level = newLevel
stats.TotalWorkSeconds += workSeconds
if err := h.Repo.UpdateRPGStats(stats); err != nil {
return 0, false, 0, err
}
if err := h.Repo.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
}
func (h *Handler) updateStreak(stats *domain.RPGStats, userID int64, date string) {
t, err := time.Parse(domain.DateLayout, date)
if err != nil {
return
}
yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout)
prevEvents, err := h.Repo.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
}
}
func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil {
return
}
day, err := h.Repo.GetDay(userID, date)
if err != nil {
return
}
y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if err := h.Repo.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil {
slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err)
}
}
func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
total, err := h.Repo.SumDailyWorkSeconds(userID)
if err != nil {
slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err)
return
}
stats, err := h.Repo.GetOrCreateRPGStats(userID)
if err != nil {
slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err)
return
}
stats.TotalWorkSeconds = total
dates, err := h.Repo.GetDatesWithEvents(userID)
today := time.Now().In(loc).Format(domain.DateLayout)
if err == nil {
stats.CurrentStreak, stats.LongestStreak = domain.ComputeStreakFromDates(dates, today)
} else {
stats.CurrentStreak = 0
stats.LongestStreak = 0
}
if err := h.Repo.UpdateRPGStats(stats); err != nil {
slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err)
}
}
// -- Achievements --
func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string {
u := &achievementUnlocker{h: h, userID: userID}
h.checkEarlyAchievements(u, workSeconds)
h.checkEventTimeAchievements(u, userID, date)
h.checkStreakAchievements(u, stats)
h.checkHourAchievements(u, stats, totalHours)
h.checkSessionAchievements(u, workSeconds, isWeekend)
h.checkLevelAchievements(u, stats)
return u.unlocked
}
type achievementUnlocker struct {
h *Handler
userID int64
unlocked []string
}
func (u *achievementUnlocker) unlock(code string) {
a, err := u.h.Repo.GetAchievementByCode(code)
if err != nil {
return
}
ok, err := u.h.Repo.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.Repo.HasAchievement(u.userID, "first_clockin")
if !has && workSeconds > 0 {
u.unlock("first_clockin")
}
}
func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) {
events, err := h.Repo.EventsForDayByDate(userID, date)
if err != nil {
return
}
for _, e := range events {
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 *domain.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 *domain.RPGStats, hoursWorked float64) {
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*domain.SecondsPerHour {
u.unlock("overtime_hero")
}
if isWeekend && workSeconds > 0 {
u.unlock("weekend_warrior")
}
}
func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.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")
}
}

View File

@@ -0,0 +1,95 @@
package handler
import (
"context"
"fmt"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- Command --
func (h *Handler) handleSalary(ctx context.Context, msg *models.Message, user *domain.User) {
text, err := h.buildSalaryEstimate(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
return
}
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
}
// -- Callback --
func (h *Handler) salaryCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, err := h.buildSalaryEstimate(user)
if err != nil {
text = err.Error()
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Builders --
func (h *Handler) buildSalaryEstimate(user *domain.User) (string, error) {
st, err := h.Repo.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 := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
calY, calM := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calY, calM)
days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "", err
}
est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc)
est.Currency = st.Currency
est.HourlyRate = st.HourlyRate
title := domain.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), domain.CurrencySymbol(st.Currency), st.HourlyRate,
est.WorkDays, est.WorkHours), nil
}
func (h *Handler) computeMonthlySalary(days []domain.Day, userID int64, hourlyRate float64, loc *time.Location) domain.SalaryEstimate {
var totalWork int64
workDayCount := 0
for _, day := range days {
if day.IsDayOff {
continue
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
continue
}
y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds > 0 {
totalWork += totals.TotalSeconds
workDayCount++
}
}
hours := float64(totalWork) / domain.SecondsPerHour
gross := domain.ComputeDailySalary(totalWork, hourlyRate)
return domain.SalaryEstimate{
WorkSeconds: totalWork, WorkHours: hours,
GrossEarning: gross, WorkDays: workDayCount,
}
}

View File

@@ -0,0 +1,720 @@
package handler
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
// -- /start and /help --
func (h *Handler) handleStart(ctx context.Context, msg *models.Message, user *domain.User) {
slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID)
kb := mainKeyboard()
h.sendWithKB(ctx, msg.Chat.ID, h.buildStatusText(user), kb)
}
func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
text := `Commands:
/start - Show main menu
/clockin - Clock in
/clockout - Clock out
/worktype - Select work type
/dayoff - Toggle day off
/report - Show today report
/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
/reporttoggle - Enable/disable auto report
/setreporttime HH:MM - Set daily report time
/setaccent - Select accent color
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
/setbreak <minutes> - Set break threshold in minutes
/setusername <name> - Set your display name
/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
/rpgtoggle - Enable/disable RPG system
/leaguetoggle - Enable/disable league participation`
h.sendText(ctx, msg.Chat.ID, text)
}
// -- Settings menu callbacks --
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
reportStatus := "off"
if st.ReportEnabled {
reportStatus = "on"
}
rpgStatus := "off"
if st.RPGEnabled {
rpgStatus = "on"
}
leagueStatus := "off"
if st.RPGEnabled && st.LeagueOptIn {
leagueStatus = "on"
}
rows := [][]models.InlineKeyboardButton{
{{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"}},
{
{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"},
{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"},
},
{
{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"},
{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"},
},
{
{Text: fmt.Sprintf("Break: %dm", breakThreshold/60), CallbackData: "breakthreshold"},
{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"},
},
{
{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"},
{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"},
},
{
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
},
{{Text: "Back to Menu", CallbackData: "back_menu"}},
}
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Toggle callbacks (binary select pickers) --
func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if !st.RPGEnabled {
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", nil)
return
}
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildToggleKeyboard(user.ID, "report", "Daily Report")
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildToggleKeyboard(userID int64, setting, label string) (string, models.InlineKeyboardMarkup) {
st, err := h.Repo.GetOrCreateSettings(userID)
current := false
if err == nil {
switch setting {
case "rpg":
current = st.RPGEnabled
case "league":
current = st.LeagueOptIn
case "report":
current = st.ReportEnabled
}
}
options := []struct {
label string
data string
value bool
}{
{"Enabled", setting + "_enable", true},
{"Disabled", setting + "_disable", false},
}
rows := [][]models.InlineKeyboardButton{}
for _, opt := range options {
l := opt.label
if opt.value == current {
l = "> " + l
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: l, CallbackData: opt.data},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) setRPGCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.RPGEnabled = enable
if enable {
h.Repo.GetOrCreateRPGStats(user.ID)
h.tryUnlockAchievement(user.ID, "rpg_pioneer")
} else {
st.LeagueOptIn = false
}
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setLeagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
if enable && !st.RPGEnabled {
h.answerCbWithText(ctx, cbID, "Enable RPG first")
return
}
st.LeagueOptIn = enable
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) setReportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ReportEnabled = enable
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Prompt callbacks (settings items that need text input) --
func (h *Handler) usernameCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingUsername,
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
}
func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingRate,
"Send me the hourly rate (e.g. 25.50):", nil)
}
func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingCurrency,
"Send me the currency (symbol and code, e.g. $ USD):\nPresets: $ USD, T Toman, IRR Rial, EUR, GBP", nil)
}
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingTimezone,
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone (e.g. Asia/Tehran, Europe/London):", user.Timezone), nil)
}
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
h.promptForInput(ctx, chatID, msgID, user.ID, PendingBreak,
"Send me the break threshold in minutes (0-480, e.g. 15):", nil)
}
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
current := "08:00"
if st != nil {
current = st.ReportTime
}
h.promptForInput(ctx, chatID, msgID, user.ID, PendingReportTime,
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM (24-hour):", current), nil)
}
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
text, kb := h.buildCalendarTypeKeyboard(user)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
text, kb := h.buildAccentKeyboard(st)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Accent picker --
func (h *Handler) buildAccentKeyboard(st *domain.UserSettings) (string, models.InlineKeyboardMarkup) {
type accentOption struct{ name, color string }
accents := []accentOption{
{"ocean", "Blue"},
{"beach", "Warm"},
{"rose", "Pink"},
{"catppuccin", "Purple"},
}
rows := [][]models.InlineKeyboardButton{}
for _, a := range accents {
label := fmt.Sprintf("%s (%s)", a.name, a.color)
if a.name == st.ExportAccent {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "accent_" + a.name},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
defer h.answerCb(ctx, cbID)
valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true}
if !valid[name] {
return
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.ExportAccent = name
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Calendar type picker --
func (h *Handler) buildCalendarTypeKeyboard(user *domain.User) (string, models.InlineKeyboardMarkup) {
cals := []string{"gregorian", "jalali", "hijri"}
calLabels := map[string]string{
"gregorian": "Gregorian",
"jalali": "Jalali",
"hijri": "Hijri",
}
rows := [][]models.InlineKeyboardButton{}
for _, c := range cals {
label := calLabels[c]
if c == user.Calendar {
label = "> " + label
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: "caltype_" + c},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Settings", CallbackData: "back_settings"},
})
return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
defer h.answerCb(ctx, cbID)
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
if !valid[name] {
return
}
user.Calendar = name
if err := h.Repo.UpdateUser(user); err != nil {
return
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Break threshold select (preset picker) --
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, valStr string) {
defer h.answerCb(ctx, cbID)
mins, err := strconv.Atoi(valStr)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
return
}
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return
}
day.MinBreakThreshold = int64(mins) * 60
h.Repo.UpdateDay(day)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Currency preset picker --
func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, currency string) {
defer h.answerCb(ctx, cbID)
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.Currency = currency
h.Repo.UpdateSettings(st)
bt := h.getTodayBreakThreshold(user)
text, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, chatID, msgID, text, &kb)
}
// -- Command implementations for settings (prompt-and-wait OR direct args) --
func (h *Handler) handleSetRate(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingRate,
"Send me the hourly rate (e.g. 25.50):", nil)
return
}
var rate float64
if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)")
return
}
h.saveRate(ctx, msg.Chat.ID, user, rate)
}
func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCurrency,
"Send me the currency (symbol and code, e.g. $ USD):", nil)
return
}
currency, errMsg := domain.ParseCurrencyInput(args)
if errMsg != "" {
h.sendText(ctx, msg.Chat.ID, errMsg)
return
}
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
}
func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/settimezone"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingTimezone,
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone:", user.Timezone), nil)
return
}
if _, err := time.LoadLocation(args); err != nil {
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", args))
return
}
h.saveTimezone(ctx, msg.Chat.ID, user, args)
}
func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setbreak"))
if args == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingBreak,
"Send me the break threshold in minutes (0-480):", nil)
return
}
mins, err := strconv.Atoi(args)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
h.sendText(ctx, msg.Chat.ID, "Invalid value. Must be between 0 and 480 minutes.")
return
}
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
}
func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, user *domain.User) {
name := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setusername"))
if name == "" {
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingUsername,
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
return
}
h.saveUsername(ctx, msg.Chat.ID, user, name)
}
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message, user *domain.User) {
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setreporttime"))
if args == "" {
st, _ := h.Repo.GetOrCreateSettings(user.ID)
current := "08:00"
if st != nil {
current = st.ReportTime
}
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingReportTime,
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM:", current), nil)
return
}
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil {
h.sendText(ctx, msg.Chat.ID, err.Error())
}
}
func (h *Handler) handleSetAccent(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
text, kb := h.buildAccentKeyboard(st)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleRPGSettings(ctx context.Context, msg *models.Message, user *domain.User) {
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) handleLeagueToggle(ctx context.Context, msg *models.Message, user *domain.User) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
return
}
if !st.RPGEnabled {
h.sendText(ctx, msg.Chat.ID, "League requires the RPG system. Enable RPG first with /rpgtoggle.")
return
}
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
// -- Pending input processors --
func (h *Handler) processRateInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
var rate float64
if _, err := fmt.Sscanf(text, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
h.editText(ctx, msg.Chat.ID, pi.MsgID, "Invalid rate. Use a positive number.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "setrate"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveRate(ctx, msg.Chat.ID, user, rate)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Hourly rate set to %s%.2f", domain.CurrencySymbol(""), rate), &backKB)
}
func (h *Handler) processCurrencyInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
currency, errMsg := domain.ParseCurrencyInput(text)
if errMsg != "" {
h.editText(ctx, msg.Chat.ID, pi.MsgID, errMsg, &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "currency"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Currency set to: %s", currency), &backKB)
}
func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
if _, err := time.LoadLocation(text); err != nil {
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", text), &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "timezone"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveTimezone(ctx, msg.Chat.ID, user, text)
loc := h.loadLocation(text)
now := time.Now().In(loc)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID,
fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB)
}
func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
mins, err := strconv.Atoi(text)
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
h.editText(ctx, msg.Chat.ID, pi.MsgID,
"Invalid value. Must be between 0 and 480 minutes.", &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "breakthreshold"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
st, _ := h.Repo.GetOrCreateSettings(user.ID)
bt := h.getTodayBreakThreshold(user)
text2, kb := h.buildSettingsKeyboard(user, st, bt)
h.editText(ctx, msg.Chat.ID, pi.MsgID, text2, &kb)
}
func (h *Handler) processUsernameInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
h.saveUsername(ctx, msg.Chat.ID, user, text)
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Username set to: %s", text), &backKB)
}
func (h *Handler) processReportTimeInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, text); err != nil {
h.editText(ctx, msg.Chat.ID, pi.MsgID, err.Error(), &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{
{{Text: "Try again", CallbackData: "reporttime"}},
{{Text: "Cancel", CallbackData: "back_settings"}},
},
})
return
}
backKB := settingsBackKeyboard()
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Report time set to %s", text), &backKB)
}
func (h *Handler) processNoteInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
eventIDStr, ok := pi.Data["event_id"]
if !ok {
return
}
var eventID int64
if _, err := fmt.Sscanf(eventIDStr, "%d", &eventID); err != nil {
return
}
note := domain.SanitizeNote(text)
if err := h.Repo.SetEventNote(eventID, note); err != nil {
slog.Error("failed to save event note", "event_id", eventID, "error", err)
return
}
event, err := h.Repo.GetEventByID(eventID, user.ID)
if err != nil {
return
}
loc := h.loadLocation(user.Timezone)
date := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.DateLayout)
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
}
// -- Shared save helpers --
func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User, rate float64) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
h.sendText(ctx, chatID, "Error saving rate")
return
}
wasZero := st.HourlyRate == 0
st.HourlyRate = float64(int(rate*domain.SalaryRoundFactor)) / 100.0
if err := h.Repo.UpdateSettings(st); err != nil {
h.sendText(ctx, chatID, "Error saving rate")
return
}
if wasZero {
h.tryUnlockAchievement(user.ID, "salary_setter")
}
}
func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) {
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return
}
st.Currency = currency
h.Repo.UpdateSettings(st)
}
func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) {
user.Timezone = tz
h.Repo.UpdateUser(user)
}
func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) {
loc := h.loadLocation(user.Timezone)
today := time.Now().In(loc).Format(domain.DateLayout)
day, err := h.Repo.GetOrCreateDay(user.ID, today)
if err != nil {
return
}
day.MinBreakThreshold = int64(mins) * 60
h.Repo.UpdateDay(day)
}
func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) {
name = domain.SanitizeDisplayName(name)
if name == "" {
return
}
user.Username = name
h.Repo.UpdateUser(user)
}
func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error {
if len(timeStr) != 5 || timeStr[2] != ':' {
return fmt.Errorf("invalid format. Use HH:MM (24-hour)")
}
hours, err1 := strconv.Atoi(timeStr[:2])
mins, err2 := strconv.Atoi(timeStr[3:])
if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 {
return fmt.Errorf("invalid time. Use HH:MM (24-hour)")
}
st, err := h.Repo.GetOrCreateSettings(user.ID)
if err != nil {
return fmt.Errorf("error saving report time")
}
st.ReportTime = timeStr
return h.Repo.UpdateSettings(st)
}

120
internal/handler/summary.go Normal file
View File

@@ -0,0 +1,120 @@
package handler
import (
"context"
"fmt"
"strings"
"time"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
func (h *Handler) handleSummary(ctx context.Context, msg *models.Message, user *domain.User) {
loc := h.loadLocation(user.Timezone)
now := time.Now().In(loc)
args := strings.Fields(msg.Text)
if len(args) >= 1 && args[0][0] == '/' {
args = args[1:]
}
var calY, calM int
if len(args) >= 1 {
if _, err := fmt.Sscanf(args[0], "%d-%d", &calY, &calM); err != nil || calM < 1 || calM > 12 {
h.sendText(ctx, msg.Chat.ID, "Usage: /summary [YYYY-MM] (e.g. /summary 2026-06)")
return
}
} else {
calY, calM = now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
calY, calM, _ = domain.GregorianToJalali(calY, calM, now.Day())
case "hijri":
calY, calM, _ = domain.GregorianToHijri(calY, calM, now.Day())
}
}
startStr, endStr := domain.MonthGregorianRange(user.Calendar, calY, calM)
summary := h.buildMonthlySummary(user, calY, calM, startStr, endStr, loc)
h.sendText(ctx, msg.Chat.ID, summary)
}
func (h *Handler) buildMonthlySummary(user *domain.User, calY, calM int, startStr, endStr string, loc *time.Location) string {
days, err := h.Repo.GetUserDaysInRange(user.ID, startStr, endStr)
if err != nil {
return "Error loading days"
}
title := domain.FormatMonthTitle(user.Calendar, calY, calM)
totalWork := int64(0)
totalBreak := int64(0)
dayOffCount := 0
workDayCount := 0
dayDetails := []string{}
for _, day := range days {
dayLabel := domain.FormatDateForCalendar(day.Date, user.Calendar)
if day.IsDayOff {
dayOffCount++
}
events, err := h.Repo.EventsForDayByDayID(day.ID)
if err != nil || len(events) == 0 {
if day.IsDayOff {
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
}
continue
}
y, m, d := domain.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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
if totals.TotalSeconds == 0 {
if day.IsDayOff {
dayDetails = append(dayDetails, fmt.Sprintf(" %s - Day Off", dayLabel))
}
continue
}
workDayCount++
totalWork += totals.TotalSeconds
totalBreak += totals.BreakSeconds
label := fmt.Sprintf(" %s - %s worked", dayLabel, formatDuration(totals.TotalSeconds))
if day.IsDayOff {
label += " (Day Off)"
}
dayDetails = append(dayDetails, label)
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Summary for %s\n", title))
b.WriteString(fmt.Sprintf("Work days: %d\n", workDayCount))
if dayOffCount > 0 {
b.WriteString(fmt.Sprintf("Days off: %d\n", dayOffCount))
}
b.WriteString(fmt.Sprintf("Total: %s worked", formatDuration(totalWork)))
if totalBreak > 0 {
b.WriteString(fmt.Sprintf(", %s break", formatDuration(totalBreak)))
}
st, _ := h.Repo.GetOrCreateSettings(user.ID)
if st.HourlyRate > 0 {
est := h.computeMonthlySalary(days, user.ID, st.HourlyRate, loc)
b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)",
est.FormatSalaryShort(st.Currency), est.WorkHours, est.WorkDays))
} else {
b.WriteString("\nSalary: No rate configured. Use /setrate <amount>")
}
b.WriteString("\n\nDetails:\n")
for _, d := range dayDetails {
b.WriteString(d)
b.WriteString("\n")
}
return b.String()
}

View File

@@ -0,0 +1,75 @@
package handler
import (
"context"
"fmt"
"github.com/go-telegram/bot/models"
"worktimeBot/internal/domain"
)
func (h *Handler) handleWorkType(ctx context.Context, msg *models.Message, user *domain.User) {
day, err := h.getTodayDay(user)
if err != nil {
h.sendText(ctx, msg.Chat.ID, "Error loading work types")
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
}
func (h *Handler) workTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
defer h.answerCb(ctx, cbID)
day, err := h.getTodayDay(user)
if err != nil {
kb := backKeyboard()
h.editText(ctx, chatID, msgID, "Error loading work types", &kb)
return
}
text, kb := h.buildWorkTypeKeyboard(user, day)
h.editText(ctx, chatID, msgID, text, &kb)
}
func (h *Handler) buildWorkTypeKeyboard(user *domain.User, day *domain.Day) (string, models.InlineKeyboardMarkup) {
wts, err := h.Repo.GetWorkTypes()
if err != nil || len(wts) == 0 {
return "No work types available.", backKeyboard()
}
rows := [][]models.InlineKeyboardButton{}
for _, wt := range wts {
label := wt.Name
if wt.ID == day.CurrentWorkTypeID {
label = "> " + wt.Name
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: label, CallbackData: fmt.Sprintf("worktype_%d", wt.ID)},
})
}
rows = append(rows, []models.InlineKeyboardButton{
{Text: "Back to Menu", CallbackData: "back_menu"},
})
return "Select work type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (h *Handler) selectWorkType(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, idStr string) {
defer h.answerCb(ctx, cbID)
day, err := h.getTodayDay(user)
if err != nil {
return
}
var wtID int64
if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil {
return
}
wt, err := h.Repo.GetWorkType(wtID)
if err != nil {
return
}
day.CurrentWorkTypeID = wtID
if err := h.Repo.UpdateDay(day); err != nil {
return
}
kb := backKeyboard()
h.editText(ctx, chatID, msgID, fmt.Sprintf("Work type set to: %s", wt.Name), &kb)
}