- Refactor GenerateMonthlyReport (198→70), HandleCallback (128→85), handleHistoryCallback (131→38), handleExportCallback (100→25), checkAchievements (86→25) into extracted helper functions - Add RPG system (XP, levels, streak, achievements, burnout) - Add WorkTime League leaderboard - Add salary estimation with configurable currency/rate - Add input sanitization (SanitizeNote, SanitizeDisplayName) - Add settings select-style pickers for toggles (report, RPG, league) - Add break threshold inline picker UI - Add event notes with pending state and /note command - Add multi-calendar support (Jalali, Hijri) - Add Excel export with theme picker - Fix: getTodayBreakThreshold uses user's timezone (was UTC) - Fix: acknowledgeCallback passes real callback ID - Fix: currency symbol safety with currencySymbol() helper - Fix: count work hours in summary on day-off days - Fix: unsilence all error returns from SQL Exec/Bot API/migrations - Remove stale db/schema.sql and unreferenced computeWeekTotals - Extract constants: DateLayout, TimeLayout, secondsPerHour, etc. - Add 12 new tests (XP, salary, sanitize, currency, dateutil) - Remove duplicate package doc comment in totals.go
126 lines
3.5 KiB
Go
126 lines
3.5 KiB
Go
package bot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-telegram/bot/models"
|
|
|
|
"worktimeBot/internal/db"
|
|
)
|
|
|
|
// handleSummaryMsg parses /summary [YYYY-MM] and sends a monthly summary.
|
|
func (h *Handler) handleSummaryMsg(ctx context.Context, msg *models.Message) {
|
|
user, err := h.getOrCreateUser(msg.Chat.ID)
|
|
if err != nil {
|
|
h.sendText(ctx, msg.Chat.ID, "Error loading profile")
|
|
return
|
|
}
|
|
loc := loadLocation(user.Timezone)
|
|
now := time.Now().In(loc)
|
|
|
|
args := strings.Fields(msg.Text)
|
|
if len(args) > 0 && 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, _ = gregorianToJalali(calY, calM, now.Day())
|
|
case "hijri":
|
|
calY, calM, _ = gregorianToHijri(calY, calM, now.Day())
|
|
}
|
|
}
|
|
|
|
startStr, endStr := monthGregorianRange(user.Calendar, calY, calM)
|
|
summary := h.buildMonthlySummary(user, calY, calM, startStr, endStr, loc)
|
|
h.sendText(ctx, msg.Chat.ID, summary)
|
|
}
|
|
|
|
// buildMonthlySummary returns a formatted summary for a calendar month.
|
|
func (h *Handler) buildMonthlySummary(user *db.User, calY, calM int, startStr, endStr string, loc *time.Location) string {
|
|
days, err := h.DB.GetUserDaysInRange(user.ID, startStr, endStr)
|
|
if err != nil {
|
|
return "Error loading days"
|
|
}
|
|
|
|
title := formatMonthTitle(user.Calendar, calY, calM)
|
|
|
|
totalWork := int64(0)
|
|
totalBreak := int64(0)
|
|
dayOffCount := 0
|
|
workDayCount := 0
|
|
dayDetails := []string{}
|
|
|
|
for _, day := range days {
|
|
dayLabel := formatDateForCalendar(day.Date, user.Calendar)
|
|
|
|
if day.IsDayOff {
|
|
dayOffCount++
|
|
}
|
|
|
|
events, err := h.DB.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 := parseGregorianDateKey(day.Date)
|
|
dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix()
|
|
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
|
|
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
|
if totals.TotalSeconds == 0 {
|
|
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.DB.GetOrCreateSettings(user.ID)
|
|
if st.HourlyRate > 0 {
|
|
est := computeMonthlySalary(days, h.DB, user.ID, st.HourlyRate, loc)
|
|
b.WriteString(fmt.Sprintf("\nSalary: %s (%.1f hours, %d days)",
|
|
est.formatSalary(st.Currency), est.WorkHours, est.WorkDays))
|
|
}
|
|
|
|
b.WriteString("\n\nDetails:\n")
|
|
for _, d := range dayDetails {
|
|
b.WriteString(d)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|