From 9d123316e6bcd2e3077a8ccfc3bcda59700d7fc6 Mon Sep 17 00:00:00 2001 From: db123 Date: Wed, 24 Jun 2026 09:39:15 +0330 Subject: [PATCH] feat: Hijri calendar, virtual midnight crossover, overlap rejection, WAL mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hijri (قمری) calendar added as 3rd calendar option with tabular conversion - Calendar type select menu (like accent) with gregorian/jalali/hijri - Calendar type applied to exports and reports - Midnight crossover: virtual splits at computation time (no synthetic DB events) - Handles both UTC and local timezone boundaries - Timezone changes handled naturally (recomputed on-the-fly) - Rate limiting per user ID instead of chat ID - WAL mode enabled for SQLite - Smart reply keyboard updates after every state change - Overlapping time edits rejected (must preserve in/out alternation) - Single event deletion warns if timeline needs repair - Day off no longer prevents clock in/out - Accent colors shown with descriptive names in select menu - Delete button label changed to DEL for visual distinction --- db/schema.sql | 2 + internal/bot/dateutil.go | 286 ++++++++++++++++ internal/bot/export.go | 32 +- internal/bot/handlers.go | 707 +++++++++++++++++++++++++++++++++++++-- internal/bot/totals.go | 11 +- internal/db/store.go | 51 ++- 6 files changed, 1030 insertions(+), 59 deletions(-) create mode 100644 internal/bot/dateutil.go diff --git a/db/schema.sql b/db/schema.sql index fb46b30..341f4e5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS users ( last_report_date TEXT DEFAULT NULL, export_accent TEXT NOT NULL DEFAULT 'ocean' CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')), + calendar TEXT NOT NULL DEFAULT 'gregorian' + CHECK (calendar IN ('gregorian', 'jalali', 'hijri')), created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch()) ); diff --git a/internal/bot/dateutil.go b/internal/bot/dateutil.go new file mode 100644 index 0000000..b1b6816 --- /dev/null +++ b/internal/bot/dateutil.go @@ -0,0 +1,286 @@ +package bot + +import "fmt" + +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} + +var gregMonthNames = []string{ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +} +var jalaliMonthNames = []string{ + "Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar", + "Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand", +} +var hijriMonthNames = []string{ + "Muharram", "Safar", "Rabi' I", "Rabi' II", "Jumada I", "Jumada II", + "Rajab", "Sha'ban", "Ramadan", "Shawwal", "Dhu al-Qa'dah", "Dhu al-Hijjah", +} + +func isGregorianLeap(year int) bool { + return year%4 == 0 && (year%100 != 0 || year%400 == 0) +} + +func isJalaliLeap(year int) bool { + r := year % 33 + return r == 1 || r == 5 || r == 9 || r == 13 || r == 17 || r == 22 || r == 26 || r == 30 +} + +func monthLenGreg(year, month int) int { + if month == 2 && isGregorianLeap(year) { + return 29 + } + return gregMonthDays[month-1] +} + +func monthLenJalali(year, month int) int { + if month == 12 && isJalaliLeap(year) { + return 30 + } + return jalaliMonthDays[month-1] +} + +func isHijriLeap(year int) bool { + switch year % 30 { + case 2, 5, 7, 10, 13, 16, 18, 21, 24, 27, 29: + return true + } + return false +} + +func monthLenHijri(year, month int) int { + if month == 12 && isHijriLeap(year) { + return 30 + } + if month%2 == 1 { + return 30 + } + return 29 +} + +func gregorianToJalali(gy, gm, gd int) (int, int, int) { + gdm := []int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334} + gy2 := gy - 1 + gd2 := gdm[gm-1] + gd + if gm > 2 && isGregorianLeap(gy) { + gd2++ + } + gd2 += gy2*365 + gy2/4 - gy2/100 + gy2/400 + gd2 -= 226899 + + jy := 979 + for { + jyDays := 365 + if isJalaliLeap(jy) { + jyDays = 366 + } + if gd2 < jyDays { + break + } + gd2 -= jyDays + jy++ + } + + jm := 1 + for _, d := range jalaliMonthDays { + if gd2 < d { + break + } + gd2 -= d + jm++ + } + return jy, jm, gd2 + 1 +} + +func jalaliToGregorian(jy, jm, jd int) (int, int, int) { + jy2 := jy - 979 + days := jy2 * 365 + for y := 979; y < jy; y++ { + if isJalaliLeap(y) { + days++ + } + } + + jdm := []int{0, 31, 62, 93, 124, 155, 186, 216, 246, 276, 306, 336} + days += jdm[jm-1] + jd - 1 + days += 226899 + + gy := 1 + for { + gyDays := 365 + if isGregorianLeap(gy) { + gyDays = 366 + } + if days < gyDays { + break + } + days -= gyDays + gy++ + } + + gm := 1 + for i, d := range gregMonthDays { + if gm == 2 && isGregorianLeap(gy) { + d = 29 + } + if days < d { + break + } + days -= d + gm = i + 2 + } + return gy, gm, days + 1 +} + +func hijriToGregorian(hy, hm, hd int) (int, int, int) { + days := (hy-1)*354 + (hy-1)*11/30 + for m := 1; m < hm; m++ { + days += monthLenHijri(hy, m) + } + days += hd - 1 + + jdn := 1948440 + days + + f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38 + e := 4*f + 3 + g := (e % 1461) / 4 + h := 5*g + 2 + day := (h%153)/5 + 1 + month := ((h/153 + 2) % 12) + 1 + year := e/1461 - 4716 + (14-month)/12 + return year, month, day +} + +func gregorianToHijri(gy, gm, gd int) (int, int, int) { + a := (14 - gm) / 12 + y := gy + 4800 - a + m := gm + 12*a - 3 + jdn := gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045 + + days := jdn - 1948440 + + hy := 1 + for { + yearDays := 354 + if isHijriLeap(hy) { + yearDays = 355 + } + if days < yearDays { + break + } + days -= yearDays + hy++ + } + + hm := 1 + for mm := 1; mm <= 12; mm++ { + md := monthLenHijri(hy, mm) + if days < md { + break + } + days -= md + hm++ + } + hd := days + 1 + return hy, hm, hd +} + +func dayOfWeekGregorian(gy, gm, gd int) int { + // Zeller-like: returns 0=Sun,1=Mon,...,6=Sat + if gm < 3 { + gm += 12 + gy-- + } + return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7 +} + +type calDay struct { + dayNum int + date string // YYYY-MM-DD gregorian key +} + +type calMonth struct { + title string + weekDays []string + days []calDay +} + +func buildCalendarMonth(cal string, year, month int) calMonth { + var title string + var weekDays []string + var totalDays int + + if cal == "jalali" { + title = fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year) + weekDays = []string{"Sh", "Ye", "Do", "Se", "Ch", "Pa", "Jo"} + totalDays = monthLenJalali(year, month) + + gy, gm, gd := jalaliToGregorian(year, month, 1) + dow := dayOfWeekGregorian(gy, gm, gd) + _ = dow + + var days []calDay + for d := 1; d <= totalDays; d++ { + gy, gm, gd = jalaliToGregorian(year, month, d) + days = append(days, calDay{ + dayNum: d, + date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd), + }) + } + return calMonth{title: title, weekDays: weekDays, days: days} + } + + if cal == "hijri" { + title = fmt.Sprintf("%s %d", hijriMonthNames[month-1], year) + weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + totalDays = monthLenHijri(year, month) + + gy, gm, gd := hijriToGregorian(year, month, 1) + dow := dayOfWeekGregorian(gy, gm, gd) + startOffset := (dow + 6) % 7 + + var days []calDay + for i := 0; i < startOffset; i++ { + days = append(days, calDay{dayNum: 0, date: ""}) + } + for d := 1; d <= totalDays; d++ { + gy, gm, gd = hijriToGregorian(year, month, d) + days = append(days, calDay{ + dayNum: d, + date: fmt.Sprintf("%04d-%02d-%02d", gy, gm, gd), + }) + } + return calMonth{title: title, weekDays: weekDays, days: days} + } + + title = fmt.Sprintf("%s %d", gregMonthNames[month-1], year) + weekDays = []string{"Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"} + totalDays = monthLenGreg(year, month) + + dow := dayOfWeekGregorian(year, month, 1) + // Convert Sunday=0 to Monday=0 alignment + startOffset := (dow + 6) % 7 + + var days []calDay + for i := 0; i < startOffset; i++ { + days = append(days, calDay{dayNum: 0, date: ""}) + } + for d := 1; d <= totalDays; d++ { + days = append(days, calDay{ + dayNum: d, + date: fmt.Sprintf("%04d-%02d-%02d", year, month, d), + }) + } + return calMonth{title: title, weekDays: weekDays, days: days} +} + +func gregorianDateKey(year, month, day int) string { + return fmt.Sprintf("%04d-%02d-%02d", year, month, day) +} + +func parseGregorianDateKey(key string) (int, int, int) { + var y, m, d int + fmt.Sscanf(key, "%04d-%02d-%02d", &y, &m, &d) + return y, m, d +} diff --git a/internal/bot/export.go b/internal/bot/export.go index 5351882..3757d9a 100644 --- a/internal/bot/export.go +++ b/internal/bot/export.go @@ -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 string, year int, month time.Month) ([]byte, error) { +func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent, calendar string, year int, month time.Month) ([]byte, error) { theme, ok := themes[accent] if !ok { theme = themes["ocean"] @@ -76,7 +76,15 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent strin Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }) - monthName := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006") + var monthName string + switch calendar { + case "jalali": + monthName = fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year) + case "hijri": + monthName = fmt.Sprintf("%s %d", hijriMonthNames[month-1], year) + default: + monthName = time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006") + } f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName)) f.MergeCell(sheet, "A1", "F1") f.SetCellStyle(sheet, "A1", "F1", titleStyle) @@ -120,15 +128,21 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent strin for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) { dateStr := d.Format("2006-01-02") + displayDate := dateStr + if calendar == "jalali" { + gy, gm, gd := parseGregorianDateKey(dateStr) + jy, jm, jd := gregorianToJalali(gy, gm, gd) + displayDate = fmt.Sprintf("%04d-%02d-%02d", jy, jm, jd) + } else if calendar == "hijri" { + gy, gm, gd := parseGregorianDateKey(dateStr) + hy, hm, hd := gregorianToHijri(gy, gm, gd) + displayDate = fmt.Sprintf("%04d-%02d-%02d", hy, hm, hd) + } day, exists := dayMap[dateStr] if !exists { continue } - if day.IsDayOff { - continue - } - events, err := store.EventsForDayByDayID(day.ID) if err != nil { return nil, err @@ -137,12 +151,14 @@ func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent strin continue } - totals := ComputeDailyTotals(events, day.MinBreakThreshold) + dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc).Unix() + dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 0, loc).Unix() + totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) totalWork += totals.TotalSeconds totalBreak += totals.BreakSeconds cellDate, _ := excelize.CoordinatesToCellName(1, row) - f.SetCellValue(sheet, cellDate, dateStr) + f.SetCellValue(sheet, cellDate, displayDate) f.SetCellStyle(sheet, cellDate, cellDate, dataStyle) if len(events) > 0 { diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index fa1e8c2..741e3a6 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -35,14 +35,14 @@ func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) * type actionFunc func(int64) (string, error) -func (h *Handler) isRateLimited(chatID int64) bool { +func (h *Handler) isRateLimited(userID int64) bool { h.mu.Lock() defer h.mu.Unlock() now := time.Now() - if last, ok := h.lastMsgTime[chatID]; ok && now.Sub(last) < rateLimitInterval { + if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval { return true } - h.lastMsgTime[chatID] = now + h.lastMsgTime[userID] = now return false } @@ -52,6 +52,7 @@ func (h *Handler) handleActionMsg(msg *tgbotapi.Message, fn actionFunc) { h.sendText(msg.Chat.ID, err.Error()) } else { h.sendSuccessWithMenu(msg.Chat.ID, text) + h.updateReplyKeyboard(msg.Chat.ID) } } @@ -65,6 +66,7 @@ func (h *Handler) handleActionCallback(chatID int64, msgID int, callbackID strin kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) + h.updateReplyKeyboard(chatID) } func (h *Handler) HandleMessage(update tgbotapi.Update) { @@ -72,7 +74,11 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { return } - if h.isRateLimited(msg.Chat.ID) { + 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) @@ -91,6 +97,10 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { h.handleExport(msg) case "/dayoff": h.handleActionMsg(msg, h.dayOff) + case "/history": + h.handleHistoryMsg(msg) + case "/edit": + h.handleEditMsg(msg) case "/reporttoggle": h.handleActionMsg(msg, h.toggleReport) case "/setreporttime": @@ -108,7 +118,12 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { h.Bot.Request(tgbotapi.NewCallback(cb.ID, "Unauthorized")) return } - if h.isRateLimited(cb.Message.Chat.ID) { + user, err := h.getOrCreateUser(cb.Message.Chat.ID) + if err != nil { + h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) + return + } + if h.isRateLimited(user.ID) { h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) return } @@ -127,12 +142,18 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { h.handleActionCallback(chatID, msgID, cb.ID, h.report) case "export": h.exportCallback(chatID, msgID, cb.ID) + case "noop": + h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) case "dayoff": h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) case "settings": h.settingsCallback(chatID, msgID, cb.ID) + case "caltype": + h.calTypeCallback(chatID, msgID, cb.ID) case "accent": h.accentCallback(chatID, msgID, cb.ID) + case "history": + h.historyCallback(chatID, msgID, cb.ID) case "reporttoggle": h.handleActionCallback(chatID, msgID, cb.ID, h.toggleReport) case "reporttime": @@ -150,6 +171,11 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { h.selectAccent(chatID, msgID, cb.ID, cb.Data[7:]) return } + if len(cb.Data) > 4 && cb.Data[:4] == "cal_" { + h.selectCalendar(chatID, msgID, cb.ID, cb.Data[4:]) + return + } + h.handleHistoryCallback(chatID, msgID, cb.ID, cb.Data) } } @@ -179,9 +205,6 @@ func (h *Handler) clockIn(chatID int64) (string, error) { if err != nil { return "", err } - if day.IsDayOff { - return "", errors.New("Today is a day off. Use /dayoff to remove it first.") - } last, err := h.DB.GetLastEvent(user.ID) if err != nil && err != sql.ErrNoRows { @@ -190,23 +213,11 @@ func (h *Handler) clockIn(chatID int64) (string, error) { if last != nil && last.EventType == "in" { loc, _ := time.LoadLocation(user.Timezone) - lastTime := time.Unix(last.OccurredAt, 0).In(loc) now := time.Now().In(loc) - lastDay := lastTime.Format("2006-01-02") - today := now.Format("2006-01-02") - - if lastDay == today { + lastTime := time.Unix(last.OccurredAt, 0).In(loc) + if lastTime.Format("2006-01-02") == now.Format("2006-01-02") { return "", errors.New("Already clocked in") } - - midnight := time.Date(lastTime.Year(), lastTime.Month(), lastTime.Day(), 23, 59, 59, 0, loc) - prevDay, err := h.DB.GetOrCreateDay(user.ID, lastDay) - if err != nil { - return "", err - } - if err := h.DB.CreateEvent(user.ID, prevDay.ID, "out", nil, midnight.Unix(), "auto midnight"); err != nil { - return "", err - } } loc, _ := time.LoadLocation(user.Timezone) @@ -223,9 +234,6 @@ func (h *Handler) clockOut(chatID int64) (string, error) { if err != nil { return "", err } - if day.IsDayOff { - return "", errors.New("Today is a day off. Use /dayoff to remove it first.") - } last, err := h.DB.GetLastEvent(user.ID) if err != nil { return "", err @@ -350,6 +358,15 @@ func (h *Handler) handleStart(msg *tgbotapi.Message) { h.Bot.Send(menu) } +func (h *Handler) updateReplyKeyboard(chatID int64) { + msg := tgbotapi.NewMessage(chatID, "\u200C") + msg.ReplyMarkup = h.smartKeyboard(chatID) + sent, err := h.Bot.Send(msg) + if err == nil { + h.Bot.Request(tgbotapi.NewDeleteMessage(chatID, sent.MessageID)) + } +} + func (h *Handler) handleWorkTypeMsg(msg *tgbotapi.Message) { user, day, err := h.getUserToday(msg.Chat.ID) if err != nil { @@ -418,6 +435,7 @@ func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr stri if err := h.DB.UpdateDay(day); err != nil { return } + h.updateReplyKeyboard(chatID) text := fmt.Sprintf("Work type set to: %s", wt.Name) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := backKeyboard() @@ -446,12 +464,18 @@ func (h *Handler) buildSettingsKeyboard(user *db.User) (string, tgbotapi.InlineK rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"), )) + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Calendar: %s", user.Calendar), "caltype"), + )) rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report: %s", reportStatus), "reporttoggle"), )) rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report time: %s", user.ReportTime), "reporttime"), )) + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("History", "history"), + )) rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), )) @@ -471,15 +495,23 @@ func (h *Handler) accentCallback(chatID int64, msgID int, callbackID string) { } func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { - accents := []string{"ocean", "beach", "rose", "catppuccin"} + accents := []struct { + name string + color string + }{ + {"ocean", "Blue"}, + {"beach", "Warm"}, + {"rose", "Pink"}, + {"catppuccin", "Purple"}, + } var rows [][]tgbotapi.InlineKeyboardButton for _, a := range accents { - label := a - if a == user.ExportAccent { - label = "> " + a + label := fmt.Sprintf("%s (%s)", a.name, a.color) + if a.name == user.ExportAccent { + label = "> " + label } rows = append(rows, tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a), + tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a.name), )) } rows = append(rows, tgbotapi.NewInlineKeyboardRow( @@ -502,6 +534,7 @@ func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) if err := h.DB.UpdateUser(user); err != nil { return } + h.updateReplyKeyboard(chatID) text := fmt.Sprintf("Accent set to: %s", name) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := tgbotapi.NewInlineKeyboardMarkup( @@ -542,6 +575,604 @@ func (h *Handler) backToSettings(chatID int64, msgID int, callbackID string) { h.Bot.Send(edit) } +func (h *Handler) calTypeCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildCalendarKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) buildCalendarKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { + cals := []string{"gregorian", "jalali", "hijri"} + calLabels := map[string]string{ + "gregorian": "Gregorian", + "jalali": "Jalali", + "hijri": "Hijri", + } + var rows [][]tgbotapi.InlineKeyboardButton + for _, c := range cals { + label := calLabels[c] + if c == user.Calendar { + label = "> " + label + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(label, "cal_"+c), + )) + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + )) + return "Select calendar type:", tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} + if !valid[name] { + return + } + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + user.Calendar = name + if err := h.DB.UpdateUser(user); err != nil { + return + } + h.updateReplyKeyboard(chatID) + text, kb := h.buildSettingsKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) handleHistoryMsg(msg *tgbotapi.Message) { + h.sendCalendar(msg.Chat.ID, 0, 0, 0) +} + +func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + h.editCalendar(chatID, msgID, 0, 0) +} + +func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { + date := msg.CommandArguments() + if date == "" { + h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD") + return + } + if len(date) != 10 || date[4] != '-' || date[7] != '-' { + h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD") + return + } + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading profile") + return + } + loc, _ := time.LoadLocation(user.Timezone) + h.sendDayView(msg.Chat.ID, 0, user, date, loc, true) +} + +func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, data string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + loc, _ := time.LoadLocation(user.Timezone) + + if data == "history" { + h.editCalendar(chatID, msgID, 0, 0) + return + } + + var y, m int + var date string + var eid int64 + + // cal_prev_YYYY_M + if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { + m-- + if m < 1 { + m = 12 + y-- + } + h.editCalendar(chatID, msgID, y, m) + return + } + // cal_next_YYYY_M + if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { + m++ + if m > 12 { + m = 1 + y++ + } + h.editCalendar(chatID, msgID, y, m) + return + } + // cal_day_DATE + if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { + h.editDayView(chatID, msgID, user, date, loc) + return + } + // back_day_EVENTID -> go back to day view + if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { + h.backToDayView(chatID, msgID, user, eid, loc) + return + } + // settype_EVENTID_WTID + var wtid int64 + if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { + h.setEventWorkType(chatID, msgID, user, eid, wtid, loc) + return + } + // edit_type_EVENTID -> show work type picker + if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { + h.editEventType(chatID, msgID, user, eid, loc) + return + } + // delete_EVENTID + if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { + h.deleteEventPrompt(chatID, msgID, user, eid, loc) + return + } + // delconf_EVENTID + if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { + h.deleteEventConfirm(chatID, msgID, user, eid, loc) + return + } + // edit_time_EVENTID -> show hour picker + if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { + h.editEventTime(chatID, msgID, user, eid, loc) + return + } + // edittm_EVENTID_HH_MM -> set time + var hh, mm int + if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 { + h.editEventTimeSet(chatID, msgID, user, eid, hh, mm, loc) + return + } + // edittm_EVENTID_HH -> show minute picker + if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { + h.editEventTimeMin(chatID, msgID, user, eid, hh, loc) + return + } +} + +func (h *Handler) sendCalendar(chatID int64, msgID int, year, month int) { + h.editCalendar(chatID, msgID, year, month) +} + +func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + loc, _ := time.LoadLocation(user.Timezone) + now := time.Now().In(loc) + + if year == 0 || month == 0 { + switch user.Calendar { + case "jalali": + jy, jm, _ := gregorianToJalali(now.Year(), int(now.Month()), now.Day()) + year, month = jy, jm + case "hijri": + hy, hm, _ := gregorianToHijri(now.Year(), int(now.Month()), now.Day()) + year, month = hy, hm + default: + year, month = now.Year(), int(now.Month()) + } + } + + cal := user.Calendar + cm := buildCalendarMonth(cal, year, month) + + text := cm.title + todayKey := now.Format("2006-01-02") + + hasEvent := make(map[string]bool) + if cal == "jalali" { + gy, gm, _ := jalaliToGregorian(year, month, 1) + lastDay := monthLenJalali(year, month) + gyEnd, gmEnd, gdEnd := jalaliToGregorian(year, month, lastDay) + startDate := fmt.Sprintf("%04d-%02d-%02d", gy, gm, 1) + endDate := fmt.Sprintf("%04d-%02d-%02d", gyEnd, gmEnd, gdEnd) + rows, err := h.DB.DB().Query( + "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", + user.ID, startDate, endDate, + ) + if err == nil { + for rows.Next() { + var d string + rows.Scan(&d) + hasEvent[d] = true + } + rows.Close() + } + } else if cal == "hijri" { + gy, gm, _ := hijriToGregorian(year, month, 1) + lastDay := monthLenHijri(year, month) + gyEnd, gmEnd, gdEnd := hijriToGregorian(year, month, lastDay) + startDate := fmt.Sprintf("%04d-%02d-%02d", gy, gm, 1) + endDate := fmt.Sprintf("%04d-%02d-%02d", gyEnd, gmEnd, gdEnd) + rows, err := h.DB.DB().Query( + "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", + user.ID, startDate, endDate, + ) + if err == nil { + for rows.Next() { + var d string + rows.Scan(&d) + hasEvent[d] = true + } + rows.Close() + } + } else { + startDate := fmt.Sprintf("%04d-%02d-%02d", year, month, 1) + endDate := fmt.Sprintf("%04d-%02d-%02d", year, month, monthLenGreg(year, month)) + rows, err := h.DB.DB().Query( + "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", + user.ID, startDate, endDate, + ) + if err == nil { + for rows.Next() { + var d string + rows.Scan(&d) + hasEvent[d] = true + } + rows.Close() + } + } + + var kbRows [][]tgbotapi.InlineKeyboardButton + + var headerRow []tgbotapi.InlineKeyboardButton + for _, wn := range cm.weekDays { + headerRow = append(headerRow, tgbotapi.NewInlineKeyboardButtonData(wn, "noop")) + } + kbRows = append(kbRows, headerRow) + + var row []tgbotapi.InlineKeyboardButton + for i, d := range cm.days { + if d.dayNum == 0 { + row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "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, tgbotapi.NewInlineKeyboardButtonData(label, "cal_day_"+d.date)) + } + if len(row) == 7 || i == len(cm.days)-1 { + kbRows = append(kbRows, row) + row = nil + } + } + + var prevY, prevM, nextY, nextM int + prevY, prevM = year, month-1 + if prevM < 1 { + prevM = 12 + prevY-- + } + nextY, nextM = year, month+1 + if nextM > 12 { + nextM = 1 + nextY++ + } + + navRow := tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)), + tgbotapi.NewInlineKeyboardButtonData("Today", "history"), + tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("cal_next_%d_%d", nextY, nextM)), + ) + kbRows = append(kbRows, navRow) + + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + )) + + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + + if 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) + } +} + +func (h *Handler) editDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location) { + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) { + events, err := h.DB.EventsForDayByDate(user.ID, date) + if err != nil { + if isNewMsg { + h.sendText(chatID, "Error loading events") + } + return + } + + text := fmt.Sprintf("Date: %s", date) + if len(events) == 0 { + text += "\nNo events for this day." + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + 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) + } + return + } + + text += "\n\nEvents:" + var kbRows [][]tgbotapi.InlineKeyboardButton + for _, e := range events { + t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") + label := "IN" + if e.EventType == "out" { + label = "OUT" + } + wt := "" + if e.WorkTypeID != nil { + if wtObj, err := h.DB.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, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Time %s", t), fmt.Sprintf("edit_time_%d", e.ID)), + tgbotapi.NewInlineKeyboardButtonData("Type", fmt.Sprintf("edit_type_%d", e.ID)), + tgbotapi.NewInlineKeyboardButtonData("DEL", fmt.Sprintf("delete_%d", e.ID)), + )) + } + + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), + )) + 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) + } +} + +func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + wts, err := h.DB.GetWorkTypes() + if err != nil { + return + } + text := "Select new work type:" + var kbRows [][]tgbotapi.InlineKeyboardButton + for _, wt := range wts { + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(wt.Name, fmt.Sprintf("settype_%d_%d", eventID, wt.ID)), + )) + } + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) setEventWorkType(chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) { + h.DB.DB().Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + date := t.Format("2006-01-02") + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +func (h *Handler) editEventTime(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + text := "Select hour:" + var kbRows [][]tgbotapi.InlineKeyboardButton + var hourRow []tgbotapi.InlineKeyboardButton + for hh := 0; hh < 24; hh++ { + hourRow = append(hourRow, tgbotapi.NewInlineKeyboardButtonData( + fmt.Sprintf("%02d", hh), + fmt.Sprintf("edittm_%d_%02d", eventID, hh), + )) + if len(hourRow) == 6 || hh == 23 { + kbRows = append(kbRows, hourRow) + hourRow = nil + } + } + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) editEventTimeMin(chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { + text := fmt.Sprintf("Select minute for hour %02d:", hh) + var kbRows [][]tgbotapi.InlineKeyboardButton + var minRow []tgbotapi.InlineKeyboardButton + minutes := []int{0, 15, 30, 45} + for _, mm := range minutes { + minRow = append(minRow, tgbotapi.NewInlineKeyboardButtonData( + fmt.Sprintf("%02d", mm), + fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm), + )) + } + kbRows = append(kbRows, minRow) + kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("edit_time_%d", eventID)), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) { + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc) + date := t.Format("2006-01-02") + + // Check for overlaps with adjacent events + events, err := h.DB.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.acknowledgeCallback(chatID, msgID) + h.sendDayView(chatID, msgID, user, date, loc, false) + return + } + } + type eventPos struct { + id int64 + typ string + t int64 + } + var 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 { + text := "Edit rejected: overlapping events. Two consecutive events must be different types (in/out)." + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + h.Bot.Send(edit) + return + } + } + } + + h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +func (h *Handler) acknowledgeCallback(chatID int64, msgID int) { + h.Bot.Request(tgbotapi.NewCallback(fmt.Sprintf("cb_%d_%d", chatID, msgID), "")) +} + +func (h *Handler) deleteEventPrompt(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + text := "Delete this event?" + kbRows := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Yes, DEL", fmt.Sprintf("delconf_%d", eventID)), + tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("back_day_%d", eventID)), + ), + ) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kbRows + h.Bot.Send(edit) +} + +func (h *Handler) deleteEventConfirm(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + date := t.Format("2006-01-02") + + h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID) + + // Check if remaining events need repair + events, err := h.DB.EventsForDayByDate(user.ID, date) + if err == nil && len(events) > 1 { + var hasIssue bool + for i := 1; i < len(events); i++ { + if events[i].EventType == events[i-1].EventType { + hasIssue = true + break + } + } + if hasIssue { + h.sendText(chatID, "Warning: Timeline has consecutive events of same type after deletion. Timeline requires manual repair.") + } + } + + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +func (h *Handler) backToDayView(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { + event, err := h.getEventByID(user.ID, eventID) + if err != nil { + return + } + t := time.Unix(event.OccurredAt, 0).In(loc) + date := t.Format("2006-01-02") + h.sendDayView(chatID, msgID, user, date, loc, false) +} + +func (h *Handler) getEventByID(userID, eventID int64) (*db.Event, error) { + row := h.DB.DB().QueryRow( + "SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?", + eventID, userID, + ) + var e db.Event + var wt sql.NullInt64 + if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + return nil, err + } + if wt.Valid { + e.WorkTypeID = &wt.Int64 + } + return &e, nil +} + func (h *Handler) handleExport(msg *tgbotapi.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { @@ -550,7 +1181,7 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) { } now := time.Now() slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month()) - data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, now.Year(), now.Month()) + data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, now.Year(), now.Month()) if err != nil { slog.Error("generate report", "error", err) h.sendText(msg.Chat.ID, "Error generating report") @@ -578,7 +1209,7 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { } now := time.Now() slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month()) - data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, now.Year(), now.Month()) + data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, now.Year(), now.Month()) if err != nil { slog.Error("generate report", "error", err) edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") @@ -630,7 +1261,12 @@ func (h *Handler) buildStatusText(chatID int64) string { events, err := h.DB.EventsForDayByDayID(day.ID) if err == nil && len(events) > 0 { - totals := ComputeDailyTotals(events, day.MinBreakThreshold) + loc, _ := time.LoadLocation(user.Timezone) + now := time.Now().In(loc) + y, m, d := now.Date() + dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() + dayEnd := now.Unix() + totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) } @@ -660,7 +1296,10 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event return fmt.Sprintf("%s - No events recorded.", day.Date) } - totals := ComputeDailyTotals(events, day.MinBreakThreshold) + 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) lines := fmt.Sprintf("Report for %s", day.Date) if day.IsDayOff { lines += "\nDay Off: Yes" diff --git a/internal/bot/totals.go b/internal/bot/totals.go index 96edbd8..8da0de8 100644 --- a/internal/bot/totals.go +++ b/internal/bot/totals.go @@ -20,7 +20,7 @@ type DailyTotals struct { PairCount int } -func ComputeDailyTotals(events []db.Event, minBreakThreshold int64) DailyTotals { +func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals { if len(events) == 0 { return DailyTotals{} } @@ -30,6 +30,15 @@ func ComputeDailyTotals(events []db.Event, minBreakThreshold int64) DailyTotals return sorted[i].OccurredAt < sorted[j].OccurredAt }) + if dayStart > 0 && len(sorted) > 0 && sorted[0].EventType == "out" { + first := db.Event{EventType: "in", OccurredAt: dayStart} + sorted = append([]db.Event{first}, sorted...) + } + if dayEnd > 0 && len(sorted) > 0 && sorted[len(sorted)-1].EventType == "in" { + last := db.Event{EventType: "out", OccurredAt: dayEnd} + sorted = append(sorted, last) + } + var workTotal, breakTotal int64 var workStart, breakStart int64 pairs := 0 diff --git a/internal/db/store.go b/internal/db/store.go index f8d5828..8739b52 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -26,6 +26,7 @@ type User struct { ReportTime string LastReportDate *string ExportAccent string + Calendar string CreatedAt int64 UpdatedAt int64 } @@ -67,6 +68,7 @@ func NewStore(path string) (*Store, error) { if err := db.Ping(); err != nil { return nil, err } + db.Exec("PRAGMA journal_mode=WAL") if err := migrate(db); err != nil { return nil, err } @@ -122,9 +124,12 @@ func hasColumn(db *sql.DB, table, column string) bool { func migrate(db *sql.DB) error { if hasTable(db, "users") { - return nil + if !hasColumn(db, "users", "calendar") { + slog.Info("migrating: adding calendar column to users") + db.Exec("ALTER TABLE users ADD COLUMN calendar TEXT NOT NULL DEFAULT 'gregorian' CHECK (calendar IN ('gregorian', 'jalali', 'hijri'))") + } } - if hasTable(db, "events") && !hasColumn(db, "events", "user_id") { + if hasTable(db, "events_old") || hasTable(db, "events") && !hasColumn(db, "events", "user_id") { slog.Info("migrating from old schema to new schema") if err := migrateFromOldSchema(db); err != nil { return err @@ -134,16 +139,27 @@ func migrate(db *sql.DB) error { } func migrateFromOldSchema(db *sql.DB) error { + // Rename old tables so CREATE TABLE IF NOT EXISTS can create new ones + for _, t := range []string{"events", "days_off", "settings"} { + if hasTable(db, t) { + db.Exec("ALTER TABLE " + t + " RENAME TO " + t + "_old") + } + } + if err := execSchema(db, schemaPath); err != nil { return err } + return migrateFromOldSchemaData(db) +} + +func migrateFromOldSchemaData(db *sql.DB) error { migrateUsers(db) migrateDaysOff(db) migrateEvents(db) migrateSettings(db) - for _, t := range []string{"days_off", "settings", "events"} { + for _, t := range []string{"days_off_old", "settings_old", "events_old"} { if hasTable(db, t) { db.Exec("DROP TABLE IF EXISTS " + t) } @@ -154,7 +170,10 @@ func migrateFromOldSchema(db *sql.DB) error { } func migrateUsers(db *sql.DB) { - rows, err := db.Query("SELECT DISTINCT chat_id FROM events") + if !hasTable(db, "events_old") { + return + } + rows, err := db.Query("SELECT DISTINCT chat_id FROM events_old") if err != nil { slog.Warn("migration: no events table to migrate users from") return @@ -174,10 +193,10 @@ func migrateUsers(db *sql.DB) { } func migrateDaysOff(db *sql.DB) { - if !hasTable(db, "days_off") { + if !hasTable(db, "days_off_old") { return } - rows, err := db.Query("SELECT chat_id, date, reason FROM days_off") + rows, err := db.Query("SELECT chat_id, date, reason FROM days_off_old") if err != nil { return } @@ -202,10 +221,10 @@ func migrateDaysOff(db *sql.DB) { } func migrateEvents(db *sql.DB) { - if !hasTable(db, "events") { + if !hasTable(db, "events_old") { return } - rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events ORDER BY occurred_at ASC") + rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events_old ORDER BY occurred_at ASC") if err != nil { return } @@ -239,10 +258,10 @@ func migrateEvents(db *sql.DB) { } func migrateSettings(db *sql.DB) { - if !hasTable(db, "settings") { + if !hasTable(db, "settings_old") { return } - rows, err := db.Query("SELECT chat_id, key, value FROM settings") + rows, err := db.Query("SELECT chat_id, key, value FROM settings_old") if err != nil { return } @@ -290,14 +309,14 @@ func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { func (s *Store) GetUserByChatID(chatID int64) (*User, error) { row := s.db.QueryRow(` SELECT id, chat_id, timezone, is_active, report_enabled, report_time, - last_report_date, export_accent, created_at, updated_at + last_report_date, export_accent, calendar, created_at, updated_at FROM users WHERE chat_id = ? `, chatID) var u User var lastReport sql.NullString err := row.Scan( &u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, - &lastReport, &u.ExportAccent, &u.CreatedAt, &u.UpdatedAt, + &lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ) if err != nil { return nil, err @@ -310,8 +329,8 @@ func (s *Store) GetUserByChatID(chatID int64) (*User, error) { func (s *Store) UpdateUser(user *User) error { _, err := s.db.Exec( - "UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, updated_at=unixepoch() WHERE id=?", - user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.ID, + "UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, calendar=?, updated_at=unixepoch() WHERE id=?", + user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.Calendar, user.ID, ) return err } @@ -327,7 +346,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error { func (s *Store) GetAllUsers() ([]User, error) { rows, err := s.db.Query(` SELECT id, chat_id, timezone, is_active, report_enabled, report_time, - last_report_date, export_accent, created_at, updated_at + last_report_date, export_accent, calendar, created_at, updated_at FROM users `) if err != nil { @@ -341,7 +360,7 @@ func (s *Store) GetAllUsers() ([]User, error) { var lastReport sql.NullString if err := rows.Scan( &u.ID, &u.ChatID, &u.Timezone, &u.IsActive, &u.ReportEnabled, &u.ReportTime, - &lastReport, &u.ExportAccent, &u.CreatedAt, &u.UpdatedAt, + &lastReport, &u.ExportAccent, &u.Calendar, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, err }