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:
207
internal/handler/burnout.go
Normal file
207
internal/handler/burnout.go
Normal 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
|
||||
}
|
||||
542
internal/handler/calendar.go
Normal file
542
internal/handler/calendar.go
Normal 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
222
internal/handler/clock.go
Normal 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
531
internal/handler/export.go
Normal 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
453
internal/handler/handler.go
Normal 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)
|
||||
}
|
||||
111
internal/handler/keyboard.go
Normal file
111
internal/handler/keyboard.go
Normal 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
111
internal/handler/league.go
Normal 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
91
internal/handler/note.go
Normal 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
169
internal/handler/report.go
Normal 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
336
internal/handler/rpg.go
Normal 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")
|
||||
}
|
||||
}
|
||||
95
internal/handler/salary.go
Normal file
95
internal/handler/salary.go
Normal 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,
|
||||
}
|
||||
}
|
||||
720
internal/handler/settings.go
Normal file
720
internal/handler/settings.go
Normal 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
120
internal/handler/summary.go
Normal 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()
|
||||
}
|
||||
75
internal/handler/worktype.go
Normal file
75
internal/handler/worktype.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user