refactor: split handlers.go into 5 files, redesign export month picker, pad calendar rows, improve comments
- Split 1571-line handlers.go into: handlers.go (core), clock.go, settings.go, calendar.go, report.go - Redesigned export month picker: year navigation + 12-month grid instead of prev/next month - Fixed calendar last row: pad remaining cells with empty buttons to ensure 7 columns - Added Go doc comments across all files (dateutil.go, totals.go, store.go, main.go, webhook.go)
This commit is contained in:
@@ -87,6 +87,7 @@ func main() {
|
||||
dbStore.Close()
|
||||
}
|
||||
|
||||
// startPolling runs the long-polling update loop, dispatching messages and callbacks to the handler.
|
||||
func startPolling(botAPI *tgbotapi.BotAPI, handler *bot.Handler, stop chan struct{}) {
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 30
|
||||
@@ -109,6 +110,7 @@ func startPolling(botAPI *tgbotapi.BotAPI, handler *bot.Handler, stop chan struc
|
||||
}
|
||||
}
|
||||
|
||||
// parseAllowedUsers parses a comma-separated list of Telegram user IDs into a set.
|
||||
func parseAllowedUsers(raw string) map[int64]bool {
|
||||
m := make(map[int64]bool)
|
||||
for _, s := range strings.Split(raw, ",") {
|
||||
@@ -126,6 +128,7 @@ func parseAllowedUsers(raw string) map[int64]bool {
|
||||
return m
|
||||
}
|
||||
|
||||
// dailyReportScheduler runs a periodic ticker that triggers daily report checks every 30 seconds.
|
||||
func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
@@ -140,6 +143,7 @@ func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) {
|
||||
}
|
||||
}
|
||||
|
||||
// checkDailyReports iterates all users and sends a daily report to those whose report time has arrived.
|
||||
func checkDailyReports(h *bot.Handler, store *db.Store) {
|
||||
users, err := store.GetAllUsers()
|
||||
if err != nil {
|
||||
@@ -177,6 +181,7 @@ func checkDailyReports(h *bot.Handler, store *db.Store) {
|
||||
}
|
||||
}
|
||||
|
||||
// getEnvDefault returns the environment variable value or a default if unset or empty.
|
||||
func getEnvDefault(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"worktimeBot/internal/bot"
|
||||
)
|
||||
|
||||
// startWebhook registers the webhook with Telegram and starts the HTTPS/HTTP listener loop.
|
||||
func startWebhook(botAPI *tgbotapi.BotAPI, handler *bot.Handler, webhookURL string) {
|
||||
if _, err := botAPI.Request(tgbotapi.DeleteWebhookConfig{}); err != nil {
|
||||
slog.Error("delete webhook", "error", err)
|
||||
|
||||
496
internal/bot/calendar.go
Normal file
496
internal/bot/calendar.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"worktimeBot/internal/db"
|
||||
)
|
||||
|
||||
// handleHistoryMsg shows the calendar view (new message).
|
||||
func (h *Handler) handleHistoryMsg(msg *tgbotapi.Message) {
|
||||
h.sendCalendar(msg.Chat.ID, 0, 0, 0)
|
||||
}
|
||||
|
||||
// historyCallback opens or refreshes the calendar view (inline).
|
||||
func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) {
|
||||
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
||||
h.editCalendar(chatID, msgID, 0, 0)
|
||||
}
|
||||
|
||||
// handleEditMsg parses a /edit YYYY-MM-DD command and shows that day's events.
|
||||
func (h *Handler) handleEditMsg(msg *tgbotapi.Message) {
|
||||
date := msg.CommandArguments()
|
||||
if date == "" {
|
||||
h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD (your calendar type)")
|
||||
return
|
||||
}
|
||||
if len(date) != 10 || date[4] != '-' || date[7] != '-' {
|
||||
h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)")
|
||||
return
|
||||
}
|
||||
_, m, d := parseGregorianDateKey(date)
|
||||
if m < 1 || m > 12 || d < 1 || d > 31 {
|
||||
h.sendText(msg.Chat.ID, "Invalid date.")
|
||||
return
|
||||
}
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
loc := loadLocation(user.Timezone)
|
||||
date = userDateToGregorian(date, user.Calendar)
|
||||
h.sendDayView(msg.Chat.ID, 0, user, date, loc, true)
|
||||
}
|
||||
|
||||
// handleHistoryCallback routes calendar-related callback data to the right handler.
|
||||
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 := loadLocation(user.Timezone)
|
||||
|
||||
if data == "history" {
|
||||
h.editCalendar(chatID, msgID, 0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
var y, m int
|
||||
var date string
|
||||
var eid int64
|
||||
|
||||
// Navigate to previous month
|
||||
if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 {
|
||||
h.editCalendar(chatID, msgID, y, m)
|
||||
return
|
||||
}
|
||||
// Navigate to next month
|
||||
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
|
||||
h.editCalendar(chatID, msgID, y, m)
|
||||
return
|
||||
}
|
||||
// Open a specific day
|
||||
if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 {
|
||||
h.editDayView(chatID, msgID, user, date, loc)
|
||||
return
|
||||
}
|
||||
// Back to day view from event editing
|
||||
if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 {
|
||||
h.backToDayView(chatID, msgID, user, eid, loc)
|
||||
return
|
||||
}
|
||||
// Set event work type
|
||||
var wtid int64
|
||||
if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 {
|
||||
h.setEventWorkType(chatID, msgID, user, eid, wtid, loc)
|
||||
return
|
||||
}
|
||||
// Show work type picker for an event
|
||||
if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 {
|
||||
h.editEventType(chatID, msgID, user, eid, loc)
|
||||
return
|
||||
}
|
||||
// Delete event confirmation prompt
|
||||
if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 {
|
||||
h.deleteEventPrompt(chatID, msgID, user, eid, loc)
|
||||
return
|
||||
}
|
||||
// Confirm event deletion
|
||||
if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 {
|
||||
h.deleteEventConfirm(chatID, msgID, user, eid, loc)
|
||||
return
|
||||
}
|
||||
// Show hour picker for event time
|
||||
if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 {
|
||||
h.editEventTime(chatID, msgID, user, eid, loc)
|
||||
return
|
||||
}
|
||||
// Set event time (hour+minute)
|
||||
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
|
||||
}
|
||||
// Show minute picker (hour already chosen)
|
||||
if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 {
|
||||
h.editEventTimeMin(chatID, msgID, user, eid, hh, loc)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// sendCalendar sends a calendar view as a new message.
|
||||
func (h *Handler) sendCalendar(chatID int64, msgID int, year, month int) {
|
||||
h.editCalendar(chatID, msgID, year, month)
|
||||
}
|
||||
|
||||
// editCalendar renders a monthly calendar grid with event indicators and navigation.
|
||||
func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) {
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loc := 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)
|
||||
startDate, endDate := monthGregorianRange(cal, 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()
|
||||
}
|
||||
|
||||
kbRows := [][]tgbotapi.InlineKeyboardButton{}
|
||||
|
||||
// Weekday header row
|
||||
headerRow := []tgbotapi.InlineKeyboardButton{}
|
||||
for _, wn := range cm.weekDays {
|
||||
headerRow = append(headerRow, tgbotapi.NewInlineKeyboardButtonData(wn, "noop"))
|
||||
}
|
||||
kbRows = append(kbRows, headerRow)
|
||||
|
||||
// Day cells
|
||||
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 {
|
||||
// Pad the last row to 7 columns so buttons render evenly
|
||||
for len(row) < 7 {
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop"))
|
||||
}
|
||||
kbRows = append(kbRows, row)
|
||||
row = nil
|
||||
}
|
||||
}
|
||||
|
||||
prevY, prevM, nextY, nextM := navMonth(year, month)
|
||||
|
||||
// Navigation row
|
||||
kbRows = append(kbRows, 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, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
|
||||
))
|
||||
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
|
||||
h.sendOrEdit(chatID, msgID, text, &kb)
|
||||
}
|
||||
|
||||
// editDayView opens an existing message as a day view.
|
||||
func (h *Handler) editDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location) {
|
||||
h.sendDayView(chatID, msgID, user, date, loc, false)
|
||||
}
|
||||
|
||||
// sendDayView displays all events for a given date with inline edit/delete buttons.
|
||||
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", formatDateForCalendar(date, user.Calendar))
|
||||
if len(events) == 0 {
|
||||
text += "\nNo events for this day."
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"),
|
||||
),
|
||||
)
|
||||
h.sendOrEdit(chatID, msgID, text, &kb)
|
||||
return
|
||||
}
|
||||
|
||||
text += "\n\nEvents:"
|
||||
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...)
|
||||
h.sendOrEdit(chatID, msgID, text, &kb)
|
||||
}
|
||||
|
||||
// editEventType shows a work type picker for a specific event.
|
||||
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:"
|
||||
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)
|
||||
}
|
||||
|
||||
// setEventWorkType updates an event's work type and returns to the day view.
|
||||
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)
|
||||
h.sendDayView(chatID, msgID, user, t.Format("2006-01-02"), loc, false)
|
||||
}
|
||||
|
||||
// editEventTime shows an hour picker for changing an event's time.
|
||||
func (h *Handler) editEventTime(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
||||
text := "Select hour:"
|
||||
kbRows := [][]tgbotapi.InlineKeyboardButton{}
|
||||
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)
|
||||
}
|
||||
|
||||
// editEventTimeMin shows a minute picker (15-min intervals) after hour selection.
|
||||
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)
|
||||
kbRows := [][]tgbotapi.InlineKeyboardButton{}
|
||||
minRow := []tgbotapi.InlineKeyboardButton{}
|
||||
for _, mm := range []int{0, 15, 30, 45} {
|
||||
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)
|
||||
}
|
||||
|
||||
// editEventTimeSet applies a new time to an event, checking for overlaps.
|
||||
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")
|
||||
|
||||
// Reject if the new time collides with another event's timestamp
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that in/out events still alternate after the change
|
||||
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})
|
||||
}
|
||||
// Bubble sort by timestamp, then by ID for determinism
|
||||
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 {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Edit rejected: overlapping events. Two consecutive events must be different types (in/out).")
|
||||
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)
|
||||
}
|
||||
|
||||
// acknowledgeCallback sends an empty acknowledgement for a callback query.
|
||||
func (h *Handler) acknowledgeCallback(chatID int64, msgID int) {
|
||||
h.Bot.Request(tgbotapi.NewCallback(fmt.Sprintf("cb_%d_%d", chatID, msgID), ""))
|
||||
}
|
||||
|
||||
// deleteEventPrompt asks the user to confirm event deletion.
|
||||
func (h *Handler) deleteEventPrompt(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) {
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Delete this event?")
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Yes, DEL", fmt.Sprintf("delconf_%d", eventID)),
|
||||
tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("back_day_%d", eventID)),
|
||||
),
|
||||
)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// deleteEventConfirm deletes an event and warns if the timeline becomes invalid.
|
||||
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)
|
||||
|
||||
// Warn if consecutive events now have the same type
|
||||
events, err := h.DB.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(chatID, "Warning: Timeline has consecutive events of same type after deletion. Manual repair needed.")
|
||||
}
|
||||
}
|
||||
|
||||
h.sendDayView(chatID, msgID, user, date, loc, false)
|
||||
}
|
||||
|
||||
// backToDayView returns from event editing to the day view.
|
||||
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)
|
||||
h.sendDayView(chatID, msgID, user, t.Format("2006-01-02"), loc, false)
|
||||
}
|
||||
|
||||
// getEventByID fetches a single event by ID, scoped to the user.
|
||||
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
|
||||
}
|
||||
111
internal/bot/clock.go
Normal file
111
internal/bot/clock.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clockIn records a clock-in event for today.
|
||||
// Returns the formatted time or an error if already clocked in today.
|
||||
func (h *Handler) clockIn(chatID int64) (string, error) {
|
||||
user, day, err := h.getUserToday(chatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if last != nil && last.EventType == "in" {
|
||||
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") {
|
||||
return "", errors.New("already clocked in")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("clocked in at %s", now.Format("15:04")), nil
|
||||
}
|
||||
|
||||
// clockOut records a clock-out event for today.
|
||||
// Returns the formatted time or an error if not clocked in.
|
||||
func (h *Handler) clockOut(chatID int64) (string, error) {
|
||||
user, day, err := h.getUserToday(chatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
last, err := h.DB.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 := 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
|
||||
}
|
||||
return fmt.Sprintf("clocked out at %s", now.Format("15:04")), nil
|
||||
}
|
||||
|
||||
// dayOff toggles today as a day off.
|
||||
func (h *Handler) dayOff(chatID int64) (string, error) {
|
||||
user, day, err := h.getUserToday(chatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if day.IsDayOff {
|
||||
if err := h.DB.RemoveDayOff(user.ID, day.Date); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "day off removed", nil
|
||||
}
|
||||
if err := h.DB.SetDayOff(user.ID, day.Date, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "today marked as day off", nil
|
||||
}
|
||||
|
||||
// report returns today's work summary via buildDailyReport.
|
||||
func (h *Handler) report(chatID int64) (string, error) {
|
||||
user, day, err := h.getUserToday(chatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
events, err := h.DB.EventsForDayByDayID(day.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return h.buildDailyReport(user, day, events), nil
|
||||
}
|
||||
|
||||
// toggleReport enables or disables the automatic daily report.
|
||||
func (h *Handler) toggleReport(chatID int64) (string, error) {
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
user.ReportEnabled = !user.ReportEnabled
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if user.ReportEnabled {
|
||||
return "daily report enabled", nil
|
||||
}
|
||||
return "daily report disabled", nil
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package bot implements the Telegram bot logic: message routing, calendar rendering,
|
||||
// time tracking (multi-calendar), Excel export, and inline editing of events.
|
||||
package bot
|
||||
|
||||
import (
|
||||
@@ -63,8 +65,10 @@ func monthLenHijri(year, month int) int {
|
||||
}
|
||||
|
||||
// JDN for March 21, 622 CE (Jalali epoch: 1 Farvardin 1)
|
||||
// jalaliEpochJDN is the Julian Day Number for 1 Farvardin 1 (March 21, 622 CE).
|
||||
const jalaliEpochJDN = 1948320
|
||||
|
||||
// gregorianToJDN converts a Gregorian date to a Julian Day Number.
|
||||
func gregorianToJDN(gy, gm, gd int) int {
|
||||
a := (14 - gm) / 12
|
||||
y := gy + 4800 - a
|
||||
@@ -72,6 +76,7 @@ func gregorianToJDN(gy, gm, gd int) int {
|
||||
return gd + (153*m+2)/5 + 365*y + y/4 - y/100 + y/400 - 32045
|
||||
}
|
||||
|
||||
// jdnToGregorian converts a Julian Day Number to a Gregorian date (year, month, day).
|
||||
func jdnToGregorian(jdn int) (int, int, int) {
|
||||
f := jdn + 1401 + ((4*jdn+274277)/146097)*3/4 - 38
|
||||
e := 4*f + 3
|
||||
@@ -83,6 +88,7 @@ func jdnToGregorian(jdn int) (int, int, int) {
|
||||
return year, month, day
|
||||
}
|
||||
|
||||
// gregorianToJalali converts a Gregorian date to Jalali (Persian/Solar Hijri).
|
||||
func gregorianToJalali(gy, gm, gd int) (int, int, int) {
|
||||
jdn := gregorianToJDN(gy, gm, gd)
|
||||
days := jdn - jalaliEpochJDN
|
||||
@@ -116,6 +122,7 @@ func gregorianToJalali(gy, gm, gd int) (int, int, int) {
|
||||
return jy, jm, jd
|
||||
}
|
||||
|
||||
// jalaliToGregorian converts a Jalali (Persian/Solar Hijri) date to Gregorian.
|
||||
func jalaliToGregorian(jy, jm, jd int) (int, int, int) {
|
||||
days := 0
|
||||
for y := 1; y < jy; y++ {
|
||||
@@ -133,6 +140,7 @@ func jalaliToGregorian(jy, jm, jd int) (int, int, int) {
|
||||
return jdnToGregorian(jdn)
|
||||
}
|
||||
|
||||
// hijriToGregorian converts a Hijri (Islamic) date to Gregorian.
|
||||
func hijriToGregorian(hy, hm, hd int) (int, int, int) {
|
||||
days := (hy-1)*354 + (hy-1)*11/30
|
||||
for m := 1; m < hm; m++ {
|
||||
@@ -152,6 +160,7 @@ func hijriToGregorian(hy, hm, hd int) (int, int, int) {
|
||||
return year, month, day
|
||||
}
|
||||
|
||||
// gregorianToHijri converts a Gregorian date to Hijri (Islamic).
|
||||
func gregorianToHijri(gy, gm, gd int) (int, int, int) {
|
||||
jdn := gregorianToJDN(gy, gm, gd)
|
||||
days := jdn - 1948440
|
||||
@@ -191,17 +200,20 @@ func dayOfWeekGregorian(gy, gm, gd int) int {
|
||||
return (gd + (13*(gm+1))/5 + gy + gy/4 - gy/100 + gy/400) % 7
|
||||
}
|
||||
|
||||
// calDay represents a single day cell in a calendar month view.
|
||||
type calDay struct {
|
||||
dayNum int
|
||||
date string // YYYY-MM-DD gregorian key
|
||||
}
|
||||
|
||||
// calMonth represents a rendered calendar month with title, weekday headers, and day cells.
|
||||
type calMonth struct {
|
||||
title string
|
||||
weekDays []string
|
||||
days []calDay
|
||||
}
|
||||
|
||||
// buildCalendarMonth builds a calMonth struct for the given calendar type, year and month.
|
||||
func buildCalendarMonth(cal string, year, month int) calMonth {
|
||||
var title string
|
||||
var weekDays []string
|
||||
@@ -273,12 +285,14 @@ func buildCalendarMonth(cal string, year, month int) calMonth {
|
||||
return calMonth{title: title, weekDays: weekDays, days: days}
|
||||
}
|
||||
|
||||
// parseGregorianDateKey parses a "YYYY-MM-DD" string into year, month, day integers.
|
||||
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
|
||||
}
|
||||
|
||||
// formatDateForCalendar converts a Gregorian YYYY-MM-DD date string into the target calendar representation.
|
||||
func formatDateForCalendar(date, calType string) string {
|
||||
y, m, d := parseGregorianDateKey(date)
|
||||
switch calType {
|
||||
|
||||
@@ -2,8 +2,10 @@ package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
"github.com/xuri/excelize/v2"
|
||||
|
||||
"worktimeBot/internal/db"
|
||||
@@ -220,3 +222,220 @@ func fmtDDHHMM(seconds int64) string {
|
||||
mins := (rem % 3600) / 60
|
||||
return fmt.Sprintf("%d:%02d:%02d", days, hours, mins)
|
||||
}
|
||||
|
||||
// handleExport processes the /export command, showing the month picker or exporting directly.
|
||||
func (h *Handler) handleExport(msg *tgbotapi.Message) {
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
if len(msg.Text) > 7 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.sendExportDirect(msg.Chat.ID, user, cy, cm)
|
||||
}
|
||||
|
||||
// sendExportDirect generates and sends an Excel report for the given calendar month.
|
||||
func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) {
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err == nil && last != nil && last.EventType == "in" {
|
||||
h.sendText(chatID, "You are currently clocked in. Please clock out first, then try again.")
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
return
|
||||
}
|
||||
slog.Info("report generated", "bytes", len(data))
|
||||
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
|
||||
Bytes: data,
|
||||
})
|
||||
if _, err := h.Bot.Send(doc); err != nil {
|
||||
slog.Error("send document", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// exportCallback opens the export month picker.
|
||||
func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) {
|
||||
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
loc := loadLocation(user.Timezone)
|
||||
now := time.Now().In(loc)
|
||||
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())
|
||||
}
|
||||
h.editExportMonthPicker(chatID, msgID, cy, cm, user)
|
||||
}
|
||||
|
||||
// sendExportMonthPicker sends the export month picker as a new message.
|
||||
func (h *Handler) sendExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) {
|
||||
h.editExportMonthPicker(chatID, msgID, year, month, user)
|
||||
}
|
||||
|
||||
// editExportMonthPicker renders a year-based month picker with 12 month buttons.
|
||||
func (h *Handler) editExportMonthPicker(chatID int64, msgID int, year, _ int, user *db.User) {
|
||||
months := gregMonthNames
|
||||
switch user.Calendar {
|
||||
case "jalali":
|
||||
months = jalaliMonthNames
|
||||
case "hijri":
|
||||
months = hijriMonthNames
|
||||
}
|
||||
|
||||
kbRows := [][]tgbotapi.InlineKeyboardButton{
|
||||
// Year navigation
|
||||
{
|
||||
tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("exp_year_prev_%d", year)),
|
||||
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("%d", year), "noop"),
|
||||
tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("exp_year_next_%d", year)),
|
||||
},
|
||||
}
|
||||
|
||||
// 4 columns x 3 rows of month buttons
|
||||
for i := 0; i < 12; i += 4 {
|
||||
row := []tgbotapi.InlineKeyboardButton{}
|
||||
for j := i; j < i+4 && j < 12; j++ {
|
||||
row = append(row, tgbotapi.NewInlineKeyboardButtonData(
|
||||
months[j],
|
||||
fmt.Sprintf("exp_do_%d_%d", year, j+1),
|
||||
))
|
||||
}
|
||||
kbRows = append(kbRows, row)
|
||||
}
|
||||
|
||||
kbRows = append(kbRows, []tgbotapi.InlineKeyboardButton{
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
|
||||
})
|
||||
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...)
|
||||
h.sendOrEdit(chatID, msgID, "Select month to export:", &kb)
|
||||
}
|
||||
|
||||
// formatMonthTitle returns the localized month name and year for a given calendar.
|
||||
func formatMonthTitle(cal string, year, month int) string {
|
||||
switch cal {
|
||||
case "jalali":
|
||||
return fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year)
|
||||
case "hijri":
|
||||
return fmt.Sprintf("%s %d", hijriMonthNames[month-1], year)
|
||||
default:
|
||||
return fmt.Sprintf("%s %d", gregMonthNames[month-1], year)
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportCallback routes export inline button presses (year nav, month select).
|
||||
func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data string) {
|
||||
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var y, m int
|
||||
|
||||
// Navigate to previous year
|
||||
if n, _ := fmt.Sscanf(data, "exp_year_prev_%d", &y); n == 1 {
|
||||
h.editExportMonthPicker(chatID, msgID, y-1, 0, user)
|
||||
return
|
||||
}
|
||||
// Navigate to next year
|
||||
if n, _ := fmt.Sscanf(data, "exp_year_next_%d", &y); n == 1 {
|
||||
h.editExportMonthPicker(chatID, msgID, y+1, 0, user)
|
||||
return
|
||||
}
|
||||
|
||||
// Export a specific month
|
||||
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 {
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err == nil && last != nil && last.EventType == "in" {
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
|
||||
),
|
||||
)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, "You are currently clocked in. Please clock out first, then try again.")
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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")
|
||||
kb := backKeyboard()
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("report generated", "bytes", len(data))
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Report ready:")
|
||||
kb := backKeyboard()
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
|
||||
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
||||
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", y, m)),
|
||||
Bytes: data,
|
||||
})
|
||||
if _, err := h.Bot.Send(doc); err != nil {
|
||||
slog.Error("send document", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
149
internal/bot/report.go
Normal file
149
internal/bot/report.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"worktimeBot/internal/db"
|
||||
)
|
||||
|
||||
// buildStatusText returns the main menu status text for the current user.
|
||||
func (h *Handler) buildStatusText(chatID int64) string {
|
||||
user, day, err := h.getUserToday(chatID)
|
||||
if err != nil {
|
||||
return "Welcome. Choose an action:"
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("Today: %s", formatDateForCalendar(day.Date, user.Calendar))
|
||||
|
||||
if day.IsDayOff {
|
||||
text += "\nDay Off"
|
||||
return text + "\n\nChoose an action:"
|
||||
}
|
||||
|
||||
wt, _ := h.DB.GetWorkType(day.CurrentWorkTypeID)
|
||||
if wt != nil {
|
||||
text += fmt.Sprintf("\nWork type: %s", wt.Name)
|
||||
}
|
||||
|
||||
last, err := h.DB.GetLastEvent(user.ID)
|
||||
if err == nil && last != nil && last.EventType == "in" {
|
||||
loc := loadLocation(user.Timezone)
|
||||
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04")
|
||||
text += fmt.Sprintf("\nStatus: working since %s", t)
|
||||
} else {
|
||||
text += "\nStatus: not working"
|
||||
}
|
||||
|
||||
events, err := h.DB.EventsForDayByDayID(day.ID)
|
||||
if err == nil && len(events) > 0 {
|
||||
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()
|
||||
dayEnd := now.Unix()
|
||||
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
||||
text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds))
|
||||
}
|
||||
|
||||
text += "\n\nChoose an action:"
|
||||
return text
|
||||
}
|
||||
|
||||
// backToMenu returns to the main menu from any sub-menu.
|
||||
func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) {
|
||||
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
||||
text := h.buildStatusText(chatID)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
kb := mainKeyboard()
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// buildDailyReport builds a formatted daily report string for a given user/day.
|
||||
func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string {
|
||||
loc, err := time.LoadLocation(user.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
if len(events) == 0 {
|
||||
if day.IsDayOff {
|
||||
return fmt.Sprintf("%s - Day Off", formatDateForCalendar(day.Date, user.Calendar))
|
||||
}
|
||||
return fmt.Sprintf("%s - No events recorded.", formatDateForCalendar(day.Date, user.Calendar))
|
||||
}
|
||||
|
||||
y, m, d := 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("2006-01-02")
|
||||
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
|
||||
if day.Date == todayStr {
|
||||
dayEnd = now.Unix()
|
||||
}
|
||||
totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
||||
|
||||
lines := fmt.Sprintf("Report for %s", 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("15:04")
|
||||
label := "IN"
|
||||
if e.EventType == "out" {
|
||||
label = "OUT"
|
||||
}
|
||||
suffix := ""
|
||||
if e.WorkTypeID != nil {
|
||||
if wtObj, err := h.DB.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))
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
// SendDailyReport sends the automated daily report to the user.
|
||||
func (h *Handler) SendDailyReport(userID int64, chatID int64) {
|
||||
user, err := h.DB.GetUserByChatID(chatID)
|
||||
if err != nil {
|
||||
slog.Error("daily report: user not found", "user_id", userID)
|
||||
return
|
||||
}
|
||||
loc, err := time.LoadLocation(user.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
today := now.Format("2006-01-02")
|
||||
|
||||
day, err := h.DB.GetOrCreateDay(user.ID, today)
|
||||
if err != nil {
|
||||
slog.Error("daily report: get day", "error", err)
|
||||
return
|
||||
}
|
||||
events, err := h.DB.EventsForDayByDayID(day.ID)
|
||||
if err != nil {
|
||||
slog.Error("daily report: get events", "error", err)
|
||||
return
|
||||
}
|
||||
text := h.buildDailyReport(user, day, events)
|
||||
reply := tgbotapi.NewMessage(chatID, text)
|
||||
h.Bot.Send(reply)
|
||||
h.DB.MarkReportSent(user.ID, today)
|
||||
}
|
||||
308
internal/bot/settings.go
Normal file
308
internal/bot/settings.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||
|
||||
"worktimeBot/internal/db"
|
||||
)
|
||||
|
||||
// handleSetReportTime parses a HH:MM argument and updates the user's report time.
|
||||
func (h *Handler) handleSetReportTime(msg *tgbotapi.Message) {
|
||||
arg := msg.CommandArguments()
|
||||
if arg == "" {
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
h.sendText(msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime))
|
||||
return
|
||||
}
|
||||
if len(arg) != 5 || arg[2] != ':' {
|
||||
h.sendText(msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)")
|
||||
return
|
||||
}
|
||||
hours, err1 := strconv.Atoi(arg[:2])
|
||||
mins, err2 := strconv.Atoi(arg[3:])
|
||||
if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 {
|
||||
h.sendText(msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)")
|
||||
return
|
||||
}
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error updating report time")
|
||||
return
|
||||
}
|
||||
user.ReportTime = arg
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error updating report time")
|
||||
return
|
||||
}
|
||||
h.sendText(msg.Chat.ID, fmt.Sprintf("Report time set to %s", arg))
|
||||
}
|
||||
|
||||
// handleAccentMsg shows the accent color picker.
|
||||
func (h *Handler) handleAccentMsg(msg *tgbotapi.Message) {
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
text, kb := h.buildAccentKeyboard(user)
|
||||
reply := tgbotapi.NewMessage(msg.Chat.ID, text)
|
||||
reply.ReplyMarkup = &kb
|
||||
h.Bot.Send(reply)
|
||||
}
|
||||
|
||||
// handleSetTimezone validates and updates the user's IANA timezone.
|
||||
func (h *Handler) handleSetTimezone(msg *tgbotapi.Message) {
|
||||
arg := msg.CommandArguments()
|
||||
if arg == "" {
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
h.sendText(msg.Chat.ID, fmt.Sprintf("Current timezone: %s\nUsage: /settimezone <IANA timezone>\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone))
|
||||
return
|
||||
}
|
||||
loc, err := time.LoadLocation(arg)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", arg))
|
||||
return
|
||||
}
|
||||
user, err := h.getOrCreateUser(msg.Chat.ID)
|
||||
if err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error loading profile")
|
||||
return
|
||||
}
|
||||
user.Timezone = arg
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
h.sendText(msg.Chat.ID, "Error saving timezone")
|
||||
return
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
h.sendText(msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", arg, now.Format("15:04")))
|
||||
}
|
||||
|
||||
// settingsCallback shows the settings inline menu.
|
||||
func (h *Handler) settingsCallback(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.buildSettingsKeyboard(user)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// buildSettingsKeyboard returns the settings menu text and inline keyboard.
|
||||
func (h *Handler) buildSettingsKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) {
|
||||
reportStatus := "disabled"
|
||||
if user.ReportEnabled {
|
||||
reportStatus = "enabled"
|
||||
}
|
||||
rows := [][]tgbotapi.InlineKeyboardButton{
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Timezone: %s", user.Timezone), "timezone"),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Calendar: %s", user.Calendar), "caltype"),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report: %s", reportStatus), "reporttoggle"),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report time: %s", user.ReportTime), "reporttime"),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("History", "history"),
|
||||
),
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
|
||||
),
|
||||
}
|
||||
return "Settings:", tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
}
|
||||
|
||||
// accentCallback shows the accent color picker (inline edit).
|
||||
func (h *Handler) accentCallback(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.buildAccentKeyboard(user)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// timezoneCallback shows the current timezone and instructions to change it.
|
||||
func (h *Handler) timezoneCallback(chatID int64, msgID int, callbackID string) {
|
||||
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("Current timezone: %s\n\nUse /settimezone <name> to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"),
|
||||
),
|
||||
)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// buildAccentKeyboard returns the accent color picker keyboard.
|
||||
func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) {
|
||||
type accentOption struct {
|
||||
name string
|
||||
color string
|
||||
}
|
||||
accents := []accentOption{
|
||||
{"ocean", "Blue"},
|
||||
{"beach", "Warm"},
|
||||
{"rose", "Pink"},
|
||||
{"catppuccin", "Purple"},
|
||||
}
|
||||
rows := [][]tgbotapi.InlineKeyboardButton{}
|
||||
for _, a := range accents {
|
||||
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.name),
|
||||
))
|
||||
}
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"),
|
||||
))
|
||||
return "Select accent color:", tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
}
|
||||
|
||||
// selectAccent sets the user's export accent color after validation.
|
||||
func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) {
|
||||
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true}
|
||||
if !valid[name] {
|
||||
return
|
||||
}
|
||||
user.ExportAccent = name
|
||||
if err := h.DB.UpdateUser(user); err != nil {
|
||||
return
|
||||
}
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, fmt.Sprintf("Accent set to: %s", name))
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"),
|
||||
),
|
||||
)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// reportTimeCallback shows the current report time setting.
|
||||
func (h *Handler) reportTimeCallback(chatID int64, msgID int, callbackID string) {
|
||||
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
||||
user, err := h.getOrCreateUser(chatID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
kb := tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"),
|
||||
),
|
||||
)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// backToSettings returns to the main settings menu.
|
||||
func (h *Handler) backToSettings(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.buildSettingsKeyboard(user)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
|
||||
// calTypeCallback shows the calendar type selector.
|
||||
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)
|
||||
}
|
||||
|
||||
// buildCalendarKeyboard returns the calendar type selector keyboard.
|
||||
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",
|
||||
}
|
||||
rows := [][]tgbotapi.InlineKeyboardButton{}
|
||||
for _, c := range cals {
|
||||
label := calLabels[c]
|
||||
if c == user.Calendar {
|
||||
label = "> " + label
|
||||
}
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData(label, "caltype_"+c),
|
||||
))
|
||||
}
|
||||
rows = append(rows, tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"),
|
||||
))
|
||||
return "Select calendar type:", tgbotapi.NewInlineKeyboardMarkup(rows...)
|
||||
}
|
||||
|
||||
// selectCalendar sets the user's calendar after validation.
|
||||
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
|
||||
}
|
||||
text, kb := h.buildSettingsKeyboard(user)
|
||||
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
||||
edit.ReplyMarkup = &kb
|
||||
h.Bot.Send(edit)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Package bot implements the Telegram bot logic: message routing, time tracking,
|
||||
// calendar rendering, Excel export, and inline editing of events.
|
||||
package bot
|
||||
|
||||
import (
|
||||
@@ -6,19 +8,27 @@ import (
|
||||
"worktimeBot/internal/db"
|
||||
)
|
||||
|
||||
// State represents the current tracking state during daily totals computation.
|
||||
type State int
|
||||
|
||||
const (
|
||||
// StateIdle indicates no active work session.
|
||||
StateIdle State = iota
|
||||
// StateWorking indicates an active work session.
|
||||
StateWorking
|
||||
// StateOnBreak indicates an active break session.
|
||||
StateOnBreak
|
||||
)
|
||||
|
||||
// DailyTotals holds the computed total work and break seconds for a single day.
|
||||
type DailyTotals struct {
|
||||
TotalSeconds int64
|
||||
BreakSeconds int64
|
||||
}
|
||||
|
||||
// ComputeDailyTotals calculates the total work and break time from a list of events.
|
||||
// It sorts events chronologically, inserts synthetic start/end events when needed,
|
||||
// and tracks state transitions between idle, working, and on-break.
|
||||
func ComputeDailyTotals(events []db.Event, minBreakThreshold int64, dayStart, dayEnd int64) DailyTotals {
|
||||
if len(events) == 0 {
|
||||
return DailyTotals{}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package db provides the SQLite-backed data store for users, work types, days, and events.
|
||||
package db
|
||||
|
||||
import (
|
||||
@@ -20,10 +21,12 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
// Store wraps a SQLite database connection and provides methods for all data access.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// User represents a bot user with timezone, reporting preferences, and calendar type.
|
||||
type User struct {
|
||||
ID int64
|
||||
ChatID int64
|
||||
@@ -38,12 +41,14 @@ type User struct {
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// WorkType represents a type of work (e.g. "office", "remote") that can be assigned to a day.
|
||||
type WorkType struct {
|
||||
ID int64
|
||||
Name string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// Day represents a single day entry for a user, including day-off status and work type.
|
||||
type Day struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
@@ -56,6 +61,7 @@ type Day struct {
|
||||
UpdatedAt int64
|
||||
}
|
||||
|
||||
// Event represents a single timestamped event ("in" or "out") for a user on a given day.
|
||||
type Event struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
@@ -67,6 +73,7 @@ type Event struct {
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
// NewStore opens or creates the SQLite database at path, runs migrations, and returns a Store.
|
||||
func NewStore(path string) (*Store, error) {
|
||||
db, err := sql.Open(driverName, path)
|
||||
if err != nil {
|
||||
@@ -85,10 +92,12 @@ func NewStore(path string) (*Store, error) {
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying SQLite database connection.
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// DB returns the underlying *sql.DB for advanced use.
|
||||
func (s *Store) DB() *sql.DB {
|
||||
return s.db
|
||||
}
|
||||
@@ -295,6 +304,7 @@ func migrateSettings(db *sql.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCreateUser retrieves a user by chat ID, creating a new record if none exists.
|
||||
func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO users (chat_id) VALUES (?)",
|
||||
@@ -306,6 +316,7 @@ func (s *Store) GetOrCreateUser(chatID int64) (*User, error) {
|
||||
return s.GetUserByChatID(chatID)
|
||||
}
|
||||
|
||||
// GetUserByChatID retrieves a single user by their Telegram chat ID.
|
||||
func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
||||
@@ -327,6 +338,7 @@ func (s *Store) GetUserByChatID(chatID int64) (*User, error) {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpdateUser persists all fields of the given user record.
|
||||
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=?, calendar=?, updated_at=unixepoch() WHERE id=?",
|
||||
@@ -335,6 +347,7 @@ func (s *Store) UpdateUser(user *User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkReportSent records the last report date for a user to prevent duplicate daily reports.
|
||||
func (s *Store) MarkReportSent(userID int64, date string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE users SET last_report_date=?, updated_at=unixepoch() WHERE id=?",
|
||||
@@ -343,6 +356,7 @@ func (s *Store) MarkReportSent(userID int64, date string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllUsers returns all users in the database.
|
||||
func (s *Store) GetAllUsers() ([]User, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, chat_id, timezone, is_active, report_enabled, report_time,
|
||||
@@ -372,6 +386,7 @@ func (s *Store) GetAllUsers() ([]User, error) {
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// GetWorkTypes returns all work types ordered by ID.
|
||||
func (s *Store) GetWorkTypes() ([]WorkType, error) {
|
||||
rows, err := s.db.Query("SELECT id, name, created_at FROM work_types ORDER BY id ASC")
|
||||
if err != nil {
|
||||
@@ -389,6 +404,7 @@ func (s *Store) GetWorkTypes() ([]WorkType, error) {
|
||||
return wts, rows.Err()
|
||||
}
|
||||
|
||||
// GetWorkType returns a single work type by its ID.
|
||||
func (s *Store) GetWorkType(id int64) (*WorkType, error) {
|
||||
var wt WorkType
|
||||
err := s.db.QueryRow("SELECT id, name, created_at FROM work_types WHERE id=?", id).Scan(&wt.ID, &wt.Name, &wt.CreatedAt)
|
||||
@@ -398,6 +414,7 @@ func (s *Store) GetWorkType(id int64) (*WorkType, error) {
|
||||
return &wt, nil
|
||||
}
|
||||
|
||||
// GetOrCreateDay retrieves a day record for a user/date, creating one if it does not exist.
|
||||
func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)",
|
||||
@@ -409,6 +426,7 @@ func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) {
|
||||
return s.GetDay(userID, date)
|
||||
}
|
||||
|
||||
// GetDay returns a single day record for the given user and date.
|
||||
func (s *Store) GetDay(userID int64, date string) (*Day, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, user_id, date, is_day_off, day_off_reason,
|
||||
@@ -426,6 +444,7 @@ func (s *Store) GetDay(userID int64, date string) (*Day, error) {
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// UpdateDay persists the given day record's editable fields.
|
||||
func (s *Store) UpdateDay(day *Day) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE days SET is_day_off=?, day_off_reason=?, current_work_type_id=?, min_break_threshold=?, updated_at=unixepoch() WHERE id=?",
|
||||
@@ -434,6 +453,7 @@ func (s *Store) UpdateDay(day *Day) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetDayOff marks a user's day as a day off with an optional reason.
|
||||
func (s *Store) SetDayOff(userID int64, date string, reason string) error {
|
||||
_, err := s.GetOrCreateDay(userID, date)
|
||||
if err != nil {
|
||||
@@ -446,6 +466,7 @@ func (s *Store) SetDayOff(userID int64, date string, reason string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveDayOff clears the day-off flag for a user on the given date.
|
||||
func (s *Store) RemoveDayOff(userID int64, date string) error {
|
||||
_, err := s.db.Exec(
|
||||
"UPDATE days SET is_day_off=0, day_off_reason='', updated_at=unixepoch() WHERE user_id=? AND date=?",
|
||||
@@ -454,6 +475,7 @@ func (s *Store) RemoveDayOff(userID int64, date string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IsDayOff checks whether the given date is a day off for the user.
|
||||
func (s *Store) IsDayOff(userID int64, date string) (bool, error) {
|
||||
var isOff bool
|
||||
err := s.db.QueryRow(
|
||||
@@ -469,6 +491,7 @@ func (s *Store) IsDayOff(userID int64, date string) (bool, error) {
|
||||
return isOff, nil
|
||||
}
|
||||
|
||||
// GetUserDaysInRange returns all day records for a user within a date range (inclusive).
|
||||
func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]Day, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, date, is_day_off, day_off_reason,
|
||||
@@ -495,6 +518,7 @@ func (s *Store) GetUserDaysInRange(userID int64, startDate, endDate string) ([]D
|
||||
return days, rows.Err()
|
||||
}
|
||||
|
||||
// CreateEvent inserts a new event record (typically "in" or "out").
|
||||
func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTypeID *int64, occurredAt int64, note string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO events (user_id, day_id, event_type, work_type_id, occurred_at, note) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
@@ -503,6 +527,7 @@ func (s *Store) CreateEvent(userID int64, dayID int64, eventType string, workTyp
|
||||
return err
|
||||
}
|
||||
|
||||
// GetLastEvent returns the most recent event for the user, ordered by occurrence time.
|
||||
func (s *Store) GetLastEvent(userID int64) (*Event, error) {
|
||||
row, err := s.db.Query(`
|
||||
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
|
||||
@@ -529,6 +554,7 @@ func (s *Store) GetLastEvent(userID int64) (*Event, error) {
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// EventsForDayByDate returns all events for a user on a specific date, ordered by occurrence.
|
||||
func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at
|
||||
@@ -556,6 +582,7 @@ func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) {
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
// EventsForDayByDayID returns all events for a day record, ordered by occurrence.
|
||||
func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
|
||||
|
||||
Reference in New Issue
Block a user