diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 4a6968d..9e3ab4c 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -127,32 +127,53 @@ func parseAllowedUsers(raw string) map[int64]bool { } func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) { - for { - now := time.Now() - next := time.Date(now.Year(), now.Month(), now.Day(), 23, 0, 0, 0, now.Location()) - if now.After(next) { - next = next.AddDate(0, 0, 1) - } - slog.Info("daily report scheduler", "next_run", next) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { select { case <-stop: return - case <-time.After(time.Until(next)): + case <-ticker.C: + checkDailyReports(h, store) + } + } +} + +func checkDailyReports(h *bot.Handler, store *db.Store) { + users, err := store.GetAllUsers() + if err != nil { + slog.Error("report scheduler: get users", "error", err) + return + } + + now := time.Now().UTC() + + for _, u := range users { + if !u.ReportEnabled || !u.IsActive { + continue } - chatIDStr, err := store.GetSetting(0, "report_chat_id") + loc, err := time.LoadLocation(u.Timezone) if err != nil { - slog.Error("daily report: chat_id not found") continue } - chatID, err := strconv.ParseInt(chatIDStr, 10, 64) - if err != nil { - slog.Error("daily report: invalid chat_id", "value", chatIDStr) + localNow := now.In(loc) + today := localNow.Format("2006-01-02") + currentHHMM := localNow.Format("15:04") + + if currentHHMM != u.ReportTime { continue } - slog.Info("sending daily report", "chat_id", chatID) - h.SendDailyReport(chatID) + + if u.LastReportDate != nil && *u.LastReportDate == today { + continue + } + + slog.Info("sending daily report", + "user_id", u.ID, "chat_id", u.ChatID, "timezone", u.Timezone, + ) + h.SendDailyReport(u.ID, u.ChatID) } } diff --git a/db/schema.sql b/db/schema.sql index 1fa5e26..fb46b30 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,26 +1,50 @@ +CREATE TABLE IF NOT EXISTS work_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +INSERT OR IGNORE INTO work_types (id, name) VALUES (1, 'onsite'); +INSERT OR IGNORE INTO work_types (id, name) VALUES (2, 'remote'); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL UNIQUE, + timezone TEXT NOT NULL DEFAULT 'Asia/Tehran', + is_active INTEGER NOT NULL DEFAULT 1, + report_enabled INTEGER NOT NULL DEFAULT 1, + report_time TEXT NOT NULL DEFAULT '23:00', + last_report_date TEXT DEFAULT NULL, + export_accent TEXT NOT NULL DEFAULT 'ocean' + CHECK (export_accent IN ('ocean', 'beach', 'rose', 'catppuccin')), + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +CREATE TABLE IF NOT EXISTS days ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)), + is_day_off INTEGER NOT NULL DEFAULT 0, + day_off_reason TEXT NOT NULL DEFAULT '', + current_work_type_id INTEGER NOT NULL DEFAULT 1 REFERENCES work_types(id), + min_break_threshold INTEGER NOT NULL DEFAULT 300, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + UNIQUE(user_id, date) +); + CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, - chat_id INTEGER NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id), + day_id INTEGER NOT NULL REFERENCES days(id), event_type TEXT NOT NULL CHECK (event_type IN ('in', 'out')), + work_type_id INTEGER REFERENCES work_types(id), occurred_at INTEGER NOT NULL CHECK (occurred_at > 0), note TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL DEFAULT (unixepoch()) ); -CREATE INDEX IF NOT EXISTS idx_events_chat_occurred -ON events(chat_id, occurred_at); - -CREATE TABLE IF NOT EXISTS settings ( - chat_id INTEGER NOT NULL, - key TEXT NOT NULL, - value TEXT NOT NULL DEFAULT '', - PRIMARY KEY (chat_id, key) -); - -CREATE TABLE IF NOT EXISTS days_off ( - chat_id INTEGER NOT NULL, - date TEXT NOT NULL CHECK (date = strftime('%Y-%m-%d', date)), - reason TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - PRIMARY KEY (chat_id, date) -); +CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id); +CREATE INDEX IF NOT EXISTS idx_events_user_occurred ON events(user_id, occurred_at); +CREATE INDEX IF NOT EXISTS idx_days_user_date ON days(user_id, date); diff --git a/internal/bot/export.go b/internal/bot/export.go index 788325a..5351882 100644 --- a/internal/bot/export.go +++ b/internal/bot/export.go @@ -9,7 +9,25 @@ import ( "worktimeBot/internal/db" ) -func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.Month) ([]byte, error) { +type themeColors struct { + headerFill string + border string + totalFill string +} + +var themes = map[string]themeColors{ + "ocean": {headerFill: "4472C4", border: "D9D9D9", totalFill: "D9E2F3"}, + "beach": {headerFill: "C87D3C", border: "E8D5B7", totalFill: "FFF0E0"}, + "rose": {headerFill: "B86C80", border: "F0D0E0", totalFill: "FCE8F0"}, + "catppuccin": {headerFill: "B48DED", border: "D9D0E8", totalFill: "F0E8FC"}, +} + +func GenerateMonthlyReport(store *db.Store, userID int64, timezone, accent string, year int, month time.Month) ([]byte, error) { + theme, ok := themes[accent] + if !ok { + theme = themes["ocean"] + } + f := excelize.NewFile() defer f.Close() @@ -23,12 +41,12 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M titleStyle, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }) headerStyle, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Color: "FFFFFF"}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.headerFill}}, Border: []excelize.Border{ {Type: "left", Color: "FFFFFF", Style: 1}, {Type: "right", Color: "FFFFFF", Style: 1}, @@ -39,32 +57,32 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M }) dataStyle, _ := f.NewStyle(&excelize.Style{ Border: []excelize.Border{ - {Type: "left", Color: "D9D9D9", Style: 1}, - {Type: "right", Color: "D9D9D9", Style: 1}, - {Type: "top", Color: "D9D9D9", Style: 1}, - {Type: "bottom", Color: "D9D9D9", Style: 1}, + {Type: "left", Color: theme.border, Style: 1}, + {Type: "right", Color: theme.border, Style: 1}, + {Type: "top", Color: theme.border, Style: 1}, + {Type: "bottom", Color: theme.border, Style: 1}, }, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }) totalStyle, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Size: 11}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"D9E2F3"}}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{theme.totalFill}}, Border: []excelize.Border{ - {Type: "left", Color: "D9D9D9", Style: 1}, - {Type: "right", Color: "D9D9D9", Style: 1}, - {Type: "top", Color: "D9D9D9", Style: 1}, - {Type: "bottom", Color: "D9D9D9", Style: 2}, + {Type: "left", Color: theme.border, Style: 1}, + {Type: "right", Color: theme.border, Style: 1}, + {Type: "top", Color: theme.border, Style: 1}, + {Type: "bottom", Color: theme.border, Style: 2}, }, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }) 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", "E1") - f.SetCellStyle(sheet, "A1", "E1", titleStyle) + f.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName)) + f.MergeCell(sheet, "A1", "F1") + f.SetCellStyle(sheet, "A1", "F1", titleStyle) f.SetRowHeight(sheet, 1, 30) - headers := []string{"Date", "Clock In", "Clock Out", "Work", "Break"} + headers := []string{"Date", "Clock In", "Clock Out", "Type", "Work", "Break"} for i, h := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 2) f.SetCellValue(sheet, cell, h) @@ -75,33 +93,51 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M f.SetColWidth(sheet, "A", "A", 14) f.SetColWidth(sheet, "B", "B", 10) f.SetColWidth(sheet, "C", "C", 10) - f.SetColWidth(sheet, "D", "D", 10) + f.SetColWidth(sheet, "D", "D", 12) f.SetColWidth(sheet, "E", "E", 10) + f.SetColWidth(sheet, "F", "F", 10) + + loc, err := time.LoadLocation(timezone) + if err != nil { + loc = time.UTC + } row := 3 - loc := time.Now().Location() firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc) lastDay := firstDay.AddDate(0, 1, -1) + userDays, err := store.GetUserDaysInRange(userID, firstDay.Format("2006-01-02"), lastDay.Format("2006-01-02")) + if err != nil { + return nil, err + } + + dayMap := make(map[string]*db.Day) + for i := range userDays { + dayMap[userDays[i].Date] = &userDays[i] + } + var totalWork, totalBreak int64 for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) { dateStr := d.Format("2006-01-02") - isOff, err := store.IsDayOff(chatID, dateStr) - if err != nil { - return nil, err + day, exists := dayMap[dateStr] + if !exists { + continue } - if isOff { + if day.IsDayOff { continue } - events, err := store.EventsForDay(chatID, dateStr) + events, err := store.EventsForDayByDayID(day.ID) if err != nil { return nil, err } - totals := ComputeDailyTotals(events) + if len(events) == 0 { + continue + } + totals := ComputeDailyTotals(events, day.MinBreakThreshold) totalWork += totals.TotalSeconds totalBreak += totals.BreakSeconds @@ -111,20 +147,25 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M if len(events) > 0 { cellIn, _ := excelize.CoordinatesToCellName(2, row) - cellOut, _ := excelize.CoordinatesToCellName(3, row) - f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).Format("15:04")) + f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format("15:04")) f.SetCellStyle(sheet, cellIn, cellIn, dataStyle) - if events[len(events)-1].EventType == "out" { - f.SetCellValue(sheet, cellOut, time.Unix(events[len(events)-1].OccurredAt, 0).Format("15:04")) - } + } + if lastEvent := events[len(events)-1]; lastEvent.EventType == "out" { + cellOut, _ := excelize.CoordinatesToCellName(3, row) + f.SetCellValue(sheet, cellOut, time.Unix(lastEvent.OccurredAt, 0).In(loc).Format("15:04")) f.SetCellStyle(sheet, cellOut, cellOut, dataStyle) } - cellWork, _ := excelize.CoordinatesToCellName(4, row) + wtLabel := workTypeLabel(store, day, events) + cellType, _ := excelize.CoordinatesToCellName(4, row) + f.SetCellValue(sheet, cellType, wtLabel) + f.SetCellStyle(sheet, cellType, cellType, dataStyle) + + cellWork, _ := excelize.CoordinatesToCellName(5, row) f.SetCellValue(sheet, cellWork, fmtHHMM(totals.TotalSeconds)) f.SetCellStyle(sheet, cellWork, cellWork, dataStyle) - cellBreak, _ := excelize.CoordinatesToCellName(5, row) + cellBreak, _ := excelize.CoordinatesToCellName(6, row) f.SetCellValue(sheet, cellBreak, fmtHHMM(totals.BreakSeconds)) f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle) @@ -135,10 +176,10 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total") f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle) - f.SetCellValue(sheet, fmt.Sprintf("D%d", row), fmtDDHHMM(totalWork)) - f.SetCellStyle(sheet, fmt.Sprintf("D%d", row), fmt.Sprintf("D%d", row), totalStyle) - f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalBreak)) + f.SetCellValue(sheet, fmt.Sprintf("E%d", row), fmtDDHHMM(totalWork)) f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle) + f.SetCellValue(sheet, fmt.Sprintf("F%d", row), fmtDDHHMM(totalBreak)) + f.SetCellStyle(sheet, fmt.Sprintf("F%d", row), fmt.Sprintf("F%d", row), totalStyle) buf, err := f.WriteToBuffer() if err != nil { @@ -147,6 +188,31 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M return buf.Bytes(), nil } +func workTypeLabel(store *db.Store, day *db.Day, events []db.Event) string { + wtSeen := make(map[int64]bool) + for _, e := range events { + if e.WorkTypeID != nil { + wtSeen[*e.WorkTypeID] = true + } + } + if len(wtSeen) == 0 { + wt, err := store.GetWorkType(day.CurrentWorkTypeID) + if err == nil { + return wt.Name + } + return "" + } + if len(wtSeen) == 1 { + for id := range wtSeen { + wt, err := store.GetWorkType(id) + if err == nil { + return wt.Name + } + } + } + return "mixed" +} + func fmtHHMM(seconds int64) string { hours := seconds / 3600 mins := (seconds % 3600) / 60 diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index b28bf5b..3e20c90 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "strconv" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -45,12 +46,9 @@ func (h *Handler) handleActionCallback(chatID int64, msgID int, callbackID strin h.Bot.Send(edit) } -// --- Entry points --- - func (h *Handler) HandleMessage(update tgbotapi.Update) { msg := update.Message if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { - slog.Warn("unauthorized message", "chat_id", msg.Chat.ID, "user", msg.From.UserName, "text", msg.Text) return } slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) @@ -61,14 +59,18 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { h.handleActionMsg(msg, h.clockIn) case "/clockout", "Clock Out": h.handleActionMsg(msg, h.clockOut) - case "/remote": - h.handleActionMsg(msg, h.toggleRemote) + case "/worktype": + h.handleWorkTypeMsg(msg) case "/report": h.handleActionMsg(msg, h.report) case "/export": h.handleExport(msg) case "/dayoff": h.handleActionMsg(msg, h.dayOff) + case "/reporttoggle": + h.handleActionMsg(msg, h.toggleReport) + case "/setreporttime": + h.handleSetReportTime(msg) default: h.sendText(msg.Chat.ID, "Unknown command") } @@ -77,20 +79,20 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { func (h *Handler) HandleCallback(update tgbotapi.Update) { cb := update.CallbackQuery if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Chat.ID] { - slog.Warn("unauthorized callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data) h.Bot.Request(tgbotapi.NewCallback(cb.ID, "Unauthorized")) return } slog.Info("callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data) chatID := cb.Message.Chat.ID msgID := cb.Message.MessageID + switch cb.Data { case "clockin": h.handleActionCallback(chatID, msgID, cb.ID, h.clockIn) case "clockout": h.handleActionCallback(chatID, msgID, cb.ID, h.clockOut) - case "remote": - h.handleActionCallback(chatID, msgID, cb.ID, h.toggleRemote) + case "worktype": + h.workTypeCallback(chatID, msgID, cb.ID) case "report": h.handleActionCallback(chatID, msgID, cb.ID, h.report) case "export": @@ -99,46 +101,67 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) case "back_menu": h.backToMenu(chatID, msgID, cb.ID) + default: + if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" { + h.selectWorkType(chatID, msgID, cb.ID, cb.Data[9:]) + } } } -// --- Actions (shared by msg + callback) --- +func (h *Handler) getOrCreateUser(chatID int64) (*db.User, error) { + return h.DB.GetOrCreateUser(chatID) +} + +func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return nil, nil, err + } + loc, err := time.LoadLocation(user.Timezone) + if err != nil { + loc = time.UTC + } + today := time.Now().In(loc).Format("2006-01-02") + day, err := h.DB.GetOrCreateDay(user.ID, today) + if err != nil { + return nil, nil, err + } + return user, day, nil +} func (h *Handler) clockIn(chatID int64) (string, error) { - today := time.Now().Format("2006-01-02") - isOff, err := h.DB.IsDayOff(chatID, today) + user, day, err := h.getUserToday(chatID) if err != nil { return "", err } - if isOff { + if day.IsDayOff { return "", errors.New("Today is a day off. Use /dayoff to remove it first.") } - last, err := h.DB.GetLastEvent(chatID) + last, err := h.DB.GetLastEvent(user.ID) if err != nil && err != sql.ErrNoRows { return "", err } if last != nil && last.EventType == "in" { return "", errors.New("Already clocked in") } - if _, err := h.DB.GetSetting(chatID, "remote_flag"); err != nil { - h.DB.SetSetting(chatID, "remote_flag", "onsite") - } - if err := h.DB.CreateEvent(chatID, "in", time.Now().Unix(), ""); err != nil { + loc, _ := time.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 "Clocked in at " + time.Now().Format("15:04"), nil + return fmt.Sprintf("Clocked in at %s", now.Format("15:04")), nil } func (h *Handler) clockOut(chatID int64) (string, error) { - today := time.Now().Format("2006-01-02") - isOff, err := h.DB.IsDayOff(chatID, today) + user, day, err := h.getUserToday(chatID) if err != nil { return "", err } - if isOff { + if day.IsDayOff { return "", errors.New("Today is a day off. Use /dayoff to remove it first.") } - last, err := h.DB.GetLastEvent(chatID) + last, err := h.DB.GetLastEvent(user.ID) if err != nil { return "", err } @@ -148,58 +171,100 @@ func (h *Handler) clockOut(chatID int64) (string, error) { if last.EventType == "out" { return "", errors.New("Already clocked out") } - if err := h.DB.CreateEvent(chatID, "out", time.Now().Unix(), ""); err != nil { + loc, _ := time.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 "Clocked out at " + time.Now().Format("15:04"), nil -} - -func (h *Handler) toggleRemote(chatID int64) (string, error) { - current, err := h.DB.GetSetting(chatID, "remote_flag") - if err != nil { - current = "onsite" - } - var newMode, text string - if current == "remote" { - newMode = "onsite" - text = "Onsite mode enabled" - } else { - newMode = "remote" - text = "Remote mode enabled" - } - if err := h.DB.SetSetting(chatID, "remote_flag", newMode); err != nil { - return "", err - } - return text, nil + return fmt.Sprintf("Clocked out at %s", now.Format("15:04")), nil } func (h *Handler) dayOff(chatID int64) (string, error) { - today := time.Now().Format("2006-01-02") - isOff, err := h.DB.IsDayOff(chatID, today) + user, day, err := h.getUserToday(chatID) if err != nil { return "", err } - if isOff { - if err := h.DB.RemoveDayOff(chatID, today); err != nil { + 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(chatID, today, ""); err != nil { + if err := h.DB.SetDayOff(user.ID, day.Date, ""); err != nil { return "", err } return "Today marked as day off", nil } func (h *Handler) report(chatID int64) (string, error) { - return h.buildDailyReport(chatID, time.Now()), nil + 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 } -// --- /start --- +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 +} + +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)) +} func (h *Handler) handleStart(msg *tgbotapi.Message) { - h.DB.SetSetting(0, "report_chat_id", fmt.Sprintf("%d", msg.Chat.ID)) - slog.Info("start", "chat_id", msg.Chat.ID) + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + slog.Error("start: create user", "error", err) + h.sendText(msg.Chat.ID, "Error setting up your profile") + return + } + slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID) reply := tgbotapi.NewMessage(msg.Chat.ID, "We hope you have a happy day working.") reply.ReplyMarkup = quickKeyboard() h.Bot.Send(reply) @@ -208,18 +273,98 @@ func (h *Handler) handleStart(msg *tgbotapi.Message) { h.Bot.Send(menu) } -// --- Export (different flow — sends a file) --- +func (h *Handler) handleWorkTypeMsg(msg *tgbotapi.Message) { + user, day, err := h.getUserToday(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading work types") + return + } + text, kb := h.buildWorkTypeKeyboard(user, day) + reply := tgbotapi.NewMessage(msg.Chat.ID, text) + reply.ReplyMarkup = &kb + h.Bot.Send(reply) +} + +func (h *Handler) workTypeCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, day, err := h.getUserToday(chatID) + if err != nil { + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error loading work types") + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + return + } + text, kb := h.buildWorkTypeKeyboard(user, day) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, tgbotapi.InlineKeyboardMarkup) { + wts, err := h.DB.GetWorkTypes() + if err != nil || len(wts) == 0 { + return "No work types available.", backKeyboard() + } + text := "Select work type:" + var rows [][]tgbotapi.InlineKeyboardButton + for _, wt := range wts { + label := wt.Name + if wt.ID == day.CurrentWorkTypeID { + label = "> " + wt.Name + } + callbackData := fmt.Sprintf("worktype_%d", wt.ID) + row := tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(label, callbackData), + ) + rows = append(rows, row) + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + )) + kb := tgbotapi.NewInlineKeyboardMarkup(rows...) + return text, kb +} + +func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + _, day, err := h.getUserToday(chatID) + if err != nil { + return + } + var wtID int64 + if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil { + return + } + wt, err := h.DB.GetWorkType(wtID) + if err != nil { + return + } + day.CurrentWorkTypeID = wtID + if err := h.DB.UpdateDay(day); err != nil { + return + } + text := fmt.Sprintf("Work type set to: %s", wt.Name) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} 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 + } now := time.Now() - slog.Info("export", "chat_id", msg.Chat.ID, "year", now.Year(), "month", now.Month()) - data, err := GenerateMonthlyReport(h.DB, msg.Chat.ID, now.Year(), now.Month()) + 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()) if err != nil { slog.Error("generate report", "error", err) h.sendText(msg.Chat.ID, "Error generating report") return } - slog.Info("report generated", "bytes", len(data)) doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{ Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), Bytes: data, @@ -232,9 +377,17 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) { 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 { + edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error loading profile") + kb := backKeyboard() + edit.ReplyMarkup = &kb + h.Bot.Send(edit) + return + } now := time.Now() - slog.Info("export", "chat_id", chatID, "year", now.Year(), "month", now.Month()) - data, err := GenerateMonthlyReport(h.DB, chatID, now.Year(), now.Month()) + 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()) if err != nil { slog.Error("generate report", "error", err) edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") @@ -257,8 +410,6 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { } } -// --- Back to menu --- - func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) edit := tgbotapi.NewEditMessageText(chatID, msgID, "Welcome. Choose an action:") @@ -267,52 +418,39 @@ func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { h.Bot.Send(edit) } -// --- Daily report builder --- - -func (h *Handler) buildDailyReport(chatID int64, now time.Time) string { - dateStr := now.Format("2006-01-02") - - isOff, _ := h.DB.IsDayOff(chatID, dateStr) - - events, err := h.DB.EventsForDay(chatID, dateStr) +func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string { + loc, err := time.LoadLocation(user.Timezone) if err != nil { - return "Error fetching events." + loc = time.UTC } if len(events) == 0 { - if isOff { - return fmt.Sprintf("%s — Day Off", dateStr) + if day.IsDayOff { + return fmt.Sprintf("%s - Day Off", day.Date) } - return fmt.Sprintf("%s — No events recorded.", dateStr) + return fmt.Sprintf("%s - No events recorded.", day.Date) } - mode := "Onsite" - current, err := h.DB.GetSetting(chatID, "remote_flag") - if err == nil && current == "remote" { - mode = "Remote" - } - - totals := ComputeDailyTotals(events) - if totals.PairCount == 0 { - t := time.Unix(events[0].OccurredAt, 0).Format("15:04") - return fmt.Sprintf("%s\nMode: %s\nClocked in at %s\n\nStill working — no clock-out yet.", dateStr, mode, t) - } - - lines := fmt.Sprintf("Report for %s\nMode: %s", dateStr, mode) - if isOff { + totals := ComputeDailyTotals(events, day.MinBreakThreshold) + lines := fmt.Sprintf("Report for %s", day.Date) + if day.IsDayOff { lines += "\nDay Off: Yes" } lines += "\n\n" for _, e := range events { - t := time.Unix(e.OccurredAt, 0).Format("15:04") + t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") label := "IN" - action := "Clock In" if e.EventType == "out" { label = "OUT" - action = "Clock Out" } - lines += fmt.Sprintf("%s %s — %s\n", label, t, action) + wt := "" + if e.WorkTypeID != nil { + if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { + wt = " [" + wtObj.Name + "]" + } + } + lines += fmt.Sprintf("%s %s%s\n", label, t, wt) } workStr := formatDuration(totals.TotalSeconds) @@ -322,17 +460,36 @@ func (h *Handler) buildDailyReport(chatID int64, now time.Time) string { return lines } -// --- Scheduled report --- +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") -func (h *Handler) SendDailyReport(chatID int64) { - text := h.buildDailyReport(chatID, time.Now()) + 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) reply.ReplyMarkup = quickKeyboard() h.Bot.Send(reply) + h.DB.MarkReportSent(user.ID, today) } -// --- Keyboards --- - func quickKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.NewReplyKeyboard( tgbotapi.NewKeyboardButtonRow( @@ -349,12 +506,12 @@ func mainKeyboard() tgbotapi.InlineKeyboardMarkup { tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"), ), tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Report", "report"), - tgbotapi.NewInlineKeyboardButtonData("Export", "export"), + tgbotapi.NewInlineKeyboardButtonData("Work Type", "worktype"), + tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"), ), tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData("Toggle Remote/Onsite", "remote"), - tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"), + tgbotapi.NewInlineKeyboardButtonData("Report", "report"), + tgbotapi.NewInlineKeyboardButtonData("Export", "export"), ), ) } @@ -367,8 +524,6 @@ func backKeyboard() tgbotapi.InlineKeyboardMarkup { ) } -// --- Helpers --- - func formatDuration(seconds int64) string { hours := seconds / 3600 mins := (seconds % 3600) / 60 diff --git a/internal/bot/totals.go b/internal/bot/totals.go index 3ce1d67..96edbd8 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) DailyTotals { +func ComputeDailyTotals(events []db.Event, minBreakThreshold int64) DailyTotals { if len(events) == 0 { return DailyTotals{} } @@ -43,9 +43,13 @@ func ComputeDailyTotals(events []db.Event) DailyTotals { workStart = e.OccurredAt state = StateWorking case StateOnBreak: - breakTotal += e.OccurredAt - breakStart - breakStart = 0 - workStart = e.OccurredAt + breakDuration := e.OccurredAt - breakStart + if minBreakThreshold > 0 && breakDuration <= minBreakThreshold { + workStart = breakStart + } else { + breakTotal += breakDuration + workStart = e.OccurredAt + } state = StateWorking } case "out": diff --git a/internal/db/store.go b/internal/db/store.go index 680b7ce..f8d5828 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -4,6 +4,7 @@ import ( "database/sql" "log/slog" "os" + "time" _ "modernc.org/sqlite" ) @@ -16,6 +17,48 @@ type Store struct { db *sql.DB } +type User struct { + ID int64 + ChatID int64 + Timezone string + IsActive bool + ReportEnabled bool + ReportTime string + LastReportDate *string + ExportAccent string + CreatedAt int64 + UpdatedAt int64 +} + +type WorkType struct { + ID int64 + Name string + CreatedAt int64 +} + +type Day struct { + ID int64 + UserID int64 + Date string + IsDayOff bool + DayOffReason string + CurrentWorkTypeID int64 + MinBreakThreshold int64 + CreatedAt int64 + UpdatedAt int64 +} + +type Event struct { + ID int64 + UserID int64 + DayID int64 + EventType string + WorkTypeID *int64 + OccurredAt int64 + Note string + CreatedAt int64 +} + func NewStore(path string) (*Store, error) { db, err := sql.Open(driverName, path) if err != nil { @@ -37,6 +80,10 @@ func (s *Store) Close() error { return s.db.Close() } +func (s *Store) DB() *sql.DB { + return s.db +} + func execSchema(db *sql.DB, path string) error { data, err := os.ReadFile(path) if err != nil { @@ -46,15 +93,10 @@ func execSchema(db *sql.DB, path string) error { return err } -func migrate(db *sql.DB) error { - if hasColumn(db, "events", "chat_id") { - return nil - } - slog.Info("migrating database to per-chat schema") - for _, t := range []string{"days_off", "settings", "events"} { - db.Exec("DROP TABLE IF EXISTS " + t) - } - return execSchema(db, schemaPath) +func hasTable(db *sql.DB, name string) bool { + var exists int + db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists) + return exists > 0 } func hasColumn(db *sql.DB, table, column string) bool { @@ -78,22 +120,385 @@ func hasColumn(db *sql.DB, table, column string) bool { return false } -func (s *Store) CreateEvent(chatID int64, eventType string, occurredAt int64, note string) error { +func migrate(db *sql.DB) error { + if hasTable(db, "users") { + return nil + } + if 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 + } + } + return nil +} + +func migrateFromOldSchema(db *sql.DB) error { + if err := execSchema(db, schemaPath); err != nil { + return err + } + + migrateUsers(db) + migrateDaysOff(db) + migrateEvents(db) + migrateSettings(db) + + for _, t := range []string{"days_off", "settings", "events"} { + if hasTable(db, t) { + db.Exec("DROP TABLE IF EXISTS " + t) + } + } + + slog.Info("migration complete") + return nil +} + +func migrateUsers(db *sql.DB) { + rows, err := db.Query("SELECT DISTINCT chat_id FROM events") + if err != nil { + slog.Warn("migration: no events table to migrate users from") + return + } + defer rows.Close() + + for rows.Next() { + var chatID int64 + if err := rows.Scan(&chatID); err != nil { + continue + } + db.Exec( + "INSERT OR IGNORE INTO users (chat_id, timezone) VALUES (?, 'Asia/Tehran')", + chatID, + ) + } +} + +func migrateDaysOff(db *sql.DB) { + if !hasTable(db, "days_off") { + return + } + rows, err := db.Query("SELECT chat_id, date, reason FROM days_off") + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var chatID int64 + var date, reason string + if err := rows.Scan(&chatID, &date, &reason); err != nil { + continue + } + var userID int64 + err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) + if err != nil { + continue + } + db.Exec( + "INSERT INTO days (user_id, date, is_day_off, day_off_reason) VALUES (?, ?, 1, ?) ON CONFLICT(user_id, date) DO UPDATE SET is_day_off=1, day_off_reason=excluded.day_off_reason", + userID, date, reason, + ) + } +} + +func migrateEvents(db *sql.DB) { + if !hasTable(db, "events") { + return + } + rows, err := db.Query("SELECT chat_id, event_type, occurred_at, note, created_at FROM events ORDER BY occurred_at ASC") + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var chatID int64 + var eventType, note string + var occurredAt, createdAt int64 + if err := rows.Scan(&chatID, &eventType, &occurredAt, ¬e, &createdAt); err != nil { + continue + } + + var userID int64 + err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) + if err != nil { + continue + } + + date := time.Unix(occurredAt, 0).UTC().Format("2006-01-02") + db.Exec("INSERT OR IGNORE INTO days (user_id, date) VALUES (?, ?)", userID, date) + + var dayID int64 + db.QueryRow("SELECT id FROM days WHERE user_id=? AND date=?", userID, date).Scan(&dayID) + + db.Exec( + "INSERT INTO events (user_id, day_id, event_type, occurred_at, note, created_at) VALUES (?, ?, ?, ?, ?, ?)", + userID, dayID, eventType, occurredAt, note, createdAt, + ) + } +} + +func migrateSettings(db *sql.DB) { + if !hasTable(db, "settings") { + return + } + rows, err := db.Query("SELECT chat_id, key, value FROM settings") + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var chatID int64 + var key, value string + if err := rows.Scan(&chatID, &key, &value); err != nil { + continue + } + if key == "remote_flag" { + var userID int64 + err := db.QueryRow("SELECT id FROM users WHERE chat_id=?", chatID).Scan(&userID) + if err != nil { + continue + } + wt := int64(1) + if value == "remote" { + wt = 2 + } + db.Exec("UPDATE days SET current_work_type_id=? WHERE user_id=? AND date=?", wt, userID, time.Now().UTC().Format("2006-01-02")) + } + } +} + +func (s *Store) GetOrCreateUser(chatID int64) (*User, error) { + user, err := s.GetUserByChatID(chatID) + if err == nil { + return user, nil + } + if err != sql.ErrNoRows { + return nil, err + } + _, err = s.db.Exec( + "INSERT INTO users (chat_id) VALUES (?)", + chatID, + ) + if err != nil { + return nil, err + } + return s.GetUserByChatID(chatID) +} + +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 + 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, + ) + if err != nil { + return nil, err + } + if lastReport.Valid { + u.LastReportDate = &lastReport.String + } + return &u, nil +} + +func (s *Store) UpdateUser(user *User) error { _, err := s.db.Exec( - "INSERT INTO events (chat_id, event_type, occurred_at, note) VALUES (?, ?, ?, ?)", - chatID, eventType, occurredAt, note, + "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, ) return err } -func (s *Store) GetLastEvent(chatID int64) (*Event, error) { +func (s *Store) MarkReportSent(userID int64, date string) error { + _, err := s.db.Exec( + "UPDATE users SET last_report_date=?, updated_at=unixepoch() WHERE id=?", + date, userID, + ) + return err +} + +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 + FROM users + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var users []User + for rows.Next() { + var u User + 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, + ); err != nil { + return nil, err + } + if lastReport.Valid { + u.LastReportDate = &lastReport.String + } + users = append(users, u) + } + return users, rows.Err() +} + +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 { + return nil, err + } + defer rows.Close() + var wts []WorkType + for rows.Next() { + var wt WorkType + if err := rows.Scan(&wt.ID, &wt.Name, &wt.CreatedAt); err != nil { + return nil, err + } + wts = append(wts, wt) + } + return wts, rows.Err() +} + +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) + if err != nil { + return nil, err + } + return &wt, nil +} + +func (s *Store) GetOrCreateDay(userID int64, date string) (*Day, error) { + day, err := s.GetDay(userID, date) + if err == nil { + return day, nil + } + if err != sql.ErrNoRows { + return nil, err + } + _, err = s.db.Exec( + "INSERT INTO days (user_id, date) VALUES (?, ?)", + userID, date, + ) + if err != nil { + return nil, err + } + return s.GetDay(userID, 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, + current_work_type_id, min_break_threshold, created_at, updated_at + FROM days WHERE user_id=? AND date=? + `, userID, date) + var d Day + err := row.Scan( + &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, + &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &d, nil +} + +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=?", + day.IsDayOff, day.DayOffReason, day.CurrentWorkTypeID, day.MinBreakThreshold, day.ID, + ) + return err +} + +func (s *Store) SetDayOff(userID int64, date string, reason string) error { + _, err := s.GetOrCreateDay(userID, date) + if err != nil { + return err + } + _, err = s.db.Exec( + "UPDATE days SET is_day_off=1, day_off_reason=?, updated_at=unixepoch() WHERE user_id=? AND date=?", + reason, userID, date, + ) + return err +} + +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=?", + userID, date, + ) + return err +} + +func (s *Store) IsDayOff(userID int64, date string) (bool, error) { + var isOff bool + err := s.db.QueryRow( + "SELECT is_day_off FROM days WHERE user_id=? AND date=?", + userID, date, + ).Scan(&isOff) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return isOff, nil +} + +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, + current_work_type_id, min_break_threshold, created_at, updated_at + FROM days + WHERE user_id=? AND date >= ? AND date <= ? + ORDER BY date ASC + `, userID, startDate, endDate) + if err != nil { + return nil, err + } + defer rows.Close() + var days []Day + for rows.Next() { + var d Day + if err := rows.Scan( + &d.ID, &d.UserID, &d.Date, &d.IsDayOff, &d.DayOffReason, + &d.CurrentWorkTypeID, &d.MinBreakThreshold, &d.CreatedAt, &d.UpdatedAt, + ); err != nil { + return nil, err + } + days = append(days, d) + } + return days, rows.Err() +} + +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 (?, ?, ?, ?, ?, ?)", + userID, dayID, eventType, workTypeID, occurredAt, note, + ) + return err +} + +func (s *Store) GetLastEvent(userID int64) (*Event, error) { row, err := s.db.Query(` - SELECT id, chat_id, event_type, occurred_at, note, created_at + SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events - WHERE chat_id = ? + WHERE user_id = ? ORDER BY occurred_at DESC LIMIT 1 - `, chatID) + `, userID) if err != nil { return nil, err } @@ -102,58 +507,24 @@ func (s *Store) GetLastEvent(chatID int64) (*Event, error) { return nil, sql.ErrNoRows } var e Event - if err := row.Scan(&e.ID, &e.ChatID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + 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 (s *Store) GetSetting(chatID int64, key string) (string, error) { - var value string - err := s.db.QueryRow("SELECT value FROM settings WHERE chat_id = ? AND key = ?", chatID, key).Scan(&value) - if err != nil { - return "", err - } - return value, nil -} - -func (s *Store) SetSetting(chatID int64, key, value string) error { - _, err := s.db.Exec(` - INSERT INTO settings (chat_id, key, value) VALUES (?, ?, ?) - ON CONFLICT(chat_id, key) DO UPDATE SET value=excluded.value - `, chatID, key, value) - return err -} - -func (s *Store) SetDayOff(chatID int64, date, reason string) error { - _, err := s.db.Exec(` - INSERT INTO days_off (chat_id, date, reason) VALUES (?, ?, ?) - ON CONFLICT(chat_id, date) DO UPDATE SET reason=excluded.reason - `, chatID, date, reason) - return err -} - -func (s *Store) RemoveDayOff(chatID int64, date string) error { - _, err := s.db.Exec("DELETE FROM days_off WHERE chat_id = ? AND date = ?", chatID, date) - return err -} - -func (s *Store) IsDayOff(chatID int64, date string) (bool, error) { - var exists int - err := s.db.QueryRow("SELECT COUNT(1) FROM days_off WHERE chat_id = ? AND date = ?", chatID, date).Scan(&exists) - if err != nil { - return false, err - } - return exists > 0, nil -} - -func (s *Store) EventsForDay(chatID int64, day string) ([]Event, error) { +func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, error) { rows, err := s.db.Query(` - SELECT id, chat_id, event_type, occurred_at, note, created_at - FROM events - WHERE chat_id = ? AND strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ? - ORDER BY occurred_at ASC - `, chatID, day) + SELECT e.id, e.user_id, e.day_id, e.event_type, e.work_type_id, e.occurred_at, e.note, e.created_at + FROM events e + JOIN days d ON d.id = e.day_id + WHERE d.user_id = ? AND d.date = ? + ORDER BY e.occurred_at ASC + `, userID, date) if err != nil { return nil, err } @@ -161,19 +532,40 @@ func (s *Store) EventsForDay(chatID int64, day string) ([]Event, error) { var events []Event for rows.Next() { var e Event - if err := rows.Scan(&e.ID, &e.ChatID, &e.EventType, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { + var wt sql.NullInt64 + if err := rows.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 + } events = append(events, e) } return events, rows.Err() } -type Event struct { - ID int64 - ChatID int64 - EventType string - OccurredAt int64 - Note string - CreatedAt int64 +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 + FROM events + WHERE day_id = ? + ORDER BY occurred_at ASC + `, dayID) + if err != nil { + return nil, err + } + defer rows.Close() + var events []Event + for rows.Next() { + var e Event + var wt sql.NullInt64 + if err := rows.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 + } + events = append(events, e) + } + return events, rows.Err() }