refactor: export full Gregorian range, add timezone fallback, cleanup rate limit, configurable schema path

- GenerateMonthlyReport now takes explicit Gregorian start/end range,
  computed via monthGregorianRange, so Jalali/Hijri months spanning
  two Gregorian months are fully covered in exports.
- Added loadLocation helper (UTC fallback on error) and replaced all
  11 ignored-error call sites in handlers + export.
- Added periodicCleanup goroutine in NewHandler to prevent unbounded
  growth of the rateLimit map.
- schemaPath is now read from SCHEMA_PATH env var (init), defaulting
  to /app/db/schema.sql.
- Migrated sendDayView to use sendOrEdit helper, removing duplicated
  send/edit branching.
- Removed dead code: userYearMonthToGregorian (unused), the old
  startOffset function (replaced).
- Updated README with calendar docs, new env vars, full project tree.
- Updated .env.example and docker-compose.yml with SCHEMA_PATH.
- Simplified Makefile: added fmt, tidy, check, run-dev targets;
  removed broken Make-glob prerequisite pattern.
This commit is contained in:
2026-06-24 11:21:54 +03:30
parent f89f5607aa
commit d8599657f4
8 changed files with 193 additions and 122 deletions

View File

@@ -1,6 +1,9 @@
package bot
import "fmt"
import (
"fmt"
"time"
)
var gregMonthDays = []int{31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}
var jalaliMonthDays = []int{31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29}
@@ -303,20 +306,6 @@ func userDateToGregorian(dateStr, cal string) string {
return fmt.Sprintf("%04d-%02d-%02d", y, m, d)
}
// userYearMonthToGregorian converts a (year, month) in the user's calendar
// to the corresponding Gregorian (year, month). The day is assumed to be 1.
func userYearMonthToGregorian(year, month int, cal string) (int, int) {
switch cal {
case "jalali":
gy, gm, _ := jalaliToGregorian(year, month, 1)
return gy, gm
case "hijri":
gy, gm, _ := hijriToGregorian(year, month, 1)
return gy, gm
}
return year, month
}
// monthGregorianRange returns the Gregorian start and end date strings
// for a given calendar month (cal=gregorian|jalali|hijri).
func monthGregorianRange(cal string, year, month int) (start, end string) {
@@ -340,3 +329,12 @@ func monthGregorianRange(cal string, year, month int) (start, end string) {
}
return
}
// loadLocation loads a timezone, falling back to UTC on error.
func loadLocation(tz string) *time.Location {
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}

View File

@@ -22,7 +22,7 @@ var themes = map[string]themeColors{
"catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"},
}
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, year int, month time.Month) ([]byte, error) {
func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, calYear, calMonth int, start, end time.Time) ([]byte, error) {
theme, ok := themes[accent]
if !ok {
theme = themes["ocean"]
@@ -76,16 +76,7 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
})
var jy, jm int
switch calendar {
case "jalali":
jy, jm, _ = gregorianToJalali(year, int(month), 1)
case "hijri":
jy, jm, _ = gregorianToHijri(year, int(month), 1)
default:
jy, jm = year, int(month)
}
monthName := formatMonthTitle(calendar, jy, jm)
monthName := formatMonthTitle(calendar, calYear, calMonth)
f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "F1", titleStyle)
@@ -106,16 +97,11 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
f.SetColWidth(sheet, "E", "E", 10)
f.SetColWidth(sheet, "F", "F", 10)
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
loc := loadLocation(timezone)
row := 3
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc)
lastDay := firstDay.AddDate(0, 1, -1)
userDays, err := store.GetUserDaysInRange(userID, firstDay.Format("2006-01-02"), lastDay.Format("2006-01-02"))
userDays, err := store.GetUserDaysInRange(userID, start.Format("2006-01-02"), end.Format("2006-01-02"))
if err != nil {
return nil, err
}
@@ -127,7 +113,7 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, cale
var totalWork, totalBreak int64
for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) {
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02")
displayDate := formatDateForCalendar(dateStr, calendar)

View File

@@ -25,12 +25,28 @@ type Handler struct {
}
func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler {
return &Handler{
h := &Handler{
Bot: bot,
DB: store,
AllowedUsers: allowed,
lastMsgTime: make(map[int64]time.Time),
}
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)
}
}
h.mu.Unlock()
}
}
type actionFunc func(int64) (string, error)
@@ -222,7 +238,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
}
if last != nil && last.EventType == "in" {
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
lastTime := time.Unix(last.OccurredAt, 0).In(loc)
if lastTime.Format("2006-01-02") == now.Format("2006-01-02") {
@@ -230,7 +246,7 @@ func (h *Handler) clockIn(chatID int64) (string, error) {
}
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
wt := day.CurrentWorkTypeID
if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
@@ -254,7 +270,7 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
if last.EventType == "out" {
return "", errors.New("Already clocked out")
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil {
return "", err
@@ -726,7 +742,7 @@ func (h *Handler) handleEditMsg(msg *tgbotapi.Message) {
h.sendText(msg.Chat.ID, "Error loading profile")
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
date = userDateToGregorian(date, user.Calendar)
h.sendDayView(msg.Chat.ID, 0, user, date, loc, true)
}
@@ -737,7 +753,7 @@ func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, dat
if err != nil {
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
if data == "history" {
h.editCalendar(chatID, msgID, 0, 0)
@@ -816,7 +832,7 @@ func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
if err != nil {
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
if year == 0 || month == 0 {
@@ -919,15 +935,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"),
),
)
if isNewMsg || msgID == 0 {
reply := tgbotapi.NewMessage(chatID, text)
reply.ReplyMarkup = &kb
h.Bot.Send(reply)
} else {
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
}
h.sendOrEdit(chatID, msgID, text, &kb)
return
}
@@ -963,15 +971,7 @@ func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date strin
))
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
if isNewMsg || msgID == 0 {
reply := tgbotapi.NewMessage(chatID, text)
reply.ReplyMarkup = &kb
h.Bot.Send(reply)
} else {
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
}
h.sendOrEdit(chatID, msgID, text, &kb)
}
func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
@@ -1184,25 +1184,32 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) {
h.sendText(msg.Chat.ID, "Error loading profile")
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
// Default to current month in user's calendar
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
case "jalali":
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
case "hijri":
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
}
// Parse optional YYYY-MM argument (in user's calendar)
gy, gm := now.Year(), int(now.Month())
if len(msg.Text) > 7 {
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &gy, &gm); n == 2 {
if gy < 0 || gm < 1 || gm > 12 {
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 {
if cy < 0 || cm < 1 || cm > 12 {
h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
return
}
gy, gm = userYearMonthToGregorian(gy, gm, user.Calendar)
}
}
h.sendExportDirect(msg.Chat.ID, user, gy, gm)
h.sendExportDirect(msg.Chat.ID, user, cy, cm)
}
func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) {
// Check if clocked in
last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
@@ -1210,8 +1217,20 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
return
}
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
loc := loadLocation(user.Timezone)
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
start, err := time.Parse("2006-01-02", startStr)
if err != nil {
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
}
end, err := time.Parse("2006-01-02", endStr)
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 := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
if err != nil {
slog.Error("generate report", "error", err)
h.sendText(chatID, "Error generating report")
@@ -1219,7 +1238,7 @@ func (h *Handler) sendExportDirect(chatID int64, user *db.User, gy, gm int) {
}
slog.Info("report generated", "bytes", len(data))
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
Bytes: data,
})
if _, err := h.Bot.Send(doc); err != nil {
@@ -1233,7 +1252,7 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) {
if err != nil {
return
}
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
cy, cm := now.Year(), int(now.Month())
switch user.Calendar {
@@ -1313,16 +1332,20 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
h.Bot.Send(edit)
return
}
// Convert from user's calendar to Gregorian for export
gy, gm := y, m
switch user.Calendar {
case "jalali":
gy, gm, _ = jalaliToGregorian(y, m, 1)
case "hijri":
gy, gm, _ = hijriToGregorian(y, m, 1)
// Convert from user's calendar to full Gregorian range
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
start, err := time.Parse("2006-01-02", startStr)
if err != nil {
h.sendText(chatID, "Error processing date")
return
}
slog.Info("export", "user_id", user.ID, "year", gy, "month", gm, "cal", user.Calendar, "cal_y", y, "cal_m", m)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, gy, time.Month(gm))
end, err := time.Parse("2006-01-02", endStr)
if err != nil {
h.sendText(chatID, "Error processing date")
return
}
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
if err != nil {
slog.Error("generate report", "error", err)
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report")
@@ -1337,7 +1360,7 @@ func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data
edit.ReplyMarkup = &kb
h.Bot.Send(edit)
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", gy, gm)),
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
Bytes: data,
})
if _, err := h.Bot.Send(doc); err != nil {
@@ -1367,7 +1390,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
last, err := h.DB.GetLastEvent(user.ID)
if err == nil && last != nil && last.EventType == "in" {
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04")
text += fmt.Sprintf("\nStatus: working since %s", t)
} else {
@@ -1376,7 +1399,7 @@ func (h *Handler) buildStatusText(chatID int64) string {
events, err := h.DB.EventsForDayByDayID(day.ID)
if err == nil && len(events) > 0 {
loc, _ := time.LoadLocation(user.Timezone)
loc := loadLocation(user.Timezone)
now := time.Now().In(loc)
y, m, d := now.Date()
dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix()