feat: redesigned DB schema with users, work_types, days, events

This commit is contained in:
2026-06-24 00:36:16 +03:30
parent 8616ed0867
commit 2970b7d3fc
6 changed files with 904 additions and 242 deletions

View File

@@ -127,32 +127,53 @@ func parseAllowedUsers(raw string) map[int64]bool {
} }
func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) { func dailyReportScheduler(h *bot.Handler, store *db.Store, stop chan struct{}) {
for { ticker := time.NewTicker(30 * time.Second)
now := time.Now() defer ticker.Stop()
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)
for {
select { select {
case <-stop: case <-stop:
return 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 { if err != nil {
slog.Error("daily report: chat_id not found")
continue continue
} }
chatID, err := strconv.ParseInt(chatIDStr, 10, 64) localNow := now.In(loc)
if err != nil { today := localNow.Format("2006-01-02")
slog.Error("daily report: invalid chat_id", "value", chatIDStr) currentHHMM := localNow.Format("15:04")
if currentHHMM != u.ReportTime {
continue 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)
} }
} }

View File

@@ -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 ( CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT, 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')), 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), occurred_at INTEGER NOT NULL CHECK (occurred_at > 0),
note TEXT NOT NULL DEFAULT '', note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL DEFAULT (unixepoch()) created_at INTEGER NOT NULL DEFAULT (unixepoch())
); );
CREATE INDEX IF NOT EXISTS idx_events_chat_occurred CREATE INDEX IF NOT EXISTS idx_events_user_day ON events(user_id, day_id);
ON events(chat_id, occurred_at); 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);
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)
);

View File

@@ -9,7 +9,25 @@ import (
"worktimeBot/internal/db" "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() f := excelize.NewFile()
defer f.Close() defer f.Close()
@@ -23,12 +41,12 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
titleStyle, _ := f.NewStyle(&excelize.Style{ titleStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 14, Color: "FFFFFF"}, 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"}, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}) })
headerStyle, _ := f.NewStyle(&excelize.Style{ headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Color: "FFFFFF"}, 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{ Border: []excelize.Border{
{Type: "left", Color: "FFFFFF", Style: 1}, {Type: "left", Color: "FFFFFF", Style: 1},
{Type: "right", 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{ dataStyle, _ := f.NewStyle(&excelize.Style{
Border: []excelize.Border{ Border: []excelize.Border{
{Type: "left", Color: "D9D9D9", Style: 1}, {Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: "D9D9D9", Style: 1}, {Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: "D9D9D9", Style: 1}, {Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: "D9D9D9", Style: 1}, {Type: "bottom", Color: theme.border, Style: 1},
}, },
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}) })
totalStyle, _ := f.NewStyle(&excelize.Style{ totalStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 11}, 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{ Border: []excelize.Border{
{Type: "left", Color: "D9D9D9", Style: 1}, {Type: "left", Color: theme.border, Style: 1},
{Type: "right", Color: "D9D9D9", Style: 1}, {Type: "right", Color: theme.border, Style: 1},
{Type: "top", Color: "D9D9D9", Style: 1}, {Type: "top", Color: theme.border, Style: 1},
{Type: "bottom", Color: "D9D9D9", Style: 2}, {Type: "bottom", Color: theme.border, Style: 2},
}, },
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
}) })
monthName := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC).Format("January 2006") 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.SetCellValue(sheet, "A1", fmt.Sprintf("Work Time Report - %s", monthName))
f.MergeCell(sheet, "A1", "E1") f.MergeCell(sheet, "A1", "F1")
f.SetCellStyle(sheet, "A1", "E1", titleStyle) f.SetCellStyle(sheet, "A1", "F1", titleStyle)
f.SetRowHeight(sheet, 1, 30) 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 { for i, h := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 2) cell, _ := excelize.CoordinatesToCellName(i+1, 2)
f.SetCellValue(sheet, cell, h) 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, "A", "A", 14)
f.SetColWidth(sheet, "B", "B", 10) f.SetColWidth(sheet, "B", "B", 10)
f.SetColWidth(sheet, "C", "C", 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, "E", "E", 10)
f.SetColWidth(sheet, "F", "F", 10)
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
row := 3 row := 3
loc := time.Now().Location()
firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc) firstDay := time.Date(year, month, 1, 0, 0, 0, 0, loc)
lastDay := firstDay.AddDate(0, 1, -1) 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 var totalWork, totalBreak int64
for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) { for d := firstDay; !d.After(lastDay); d = d.AddDate(0, 0, 1) {
dateStr := d.Format("2006-01-02") dateStr := d.Format("2006-01-02")
isOff, err := store.IsDayOff(chatID, dateStr) day, exists := dayMap[dateStr]
if err != nil { if !exists {
return nil, err continue
} }
if isOff { if day.IsDayOff {
continue continue
} }
events, err := store.EventsForDay(chatID, dateStr) events, err := store.EventsForDayByDayID(day.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totals := ComputeDailyTotals(events) if len(events) == 0 {
continue
}
totals := ComputeDailyTotals(events, day.MinBreakThreshold)
totalWork += totals.TotalSeconds totalWork += totals.TotalSeconds
totalBreak += totals.BreakSeconds totalBreak += totals.BreakSeconds
@@ -111,20 +147,25 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
if len(events) > 0 { if len(events) > 0 {
cellIn, _ := excelize.CoordinatesToCellName(2, row) cellIn, _ := excelize.CoordinatesToCellName(2, row)
cellOut, _ := excelize.CoordinatesToCellName(3, row) f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).In(loc).Format("15:04"))
f.SetCellValue(sheet, cellIn, time.Unix(events[0].OccurredAt, 0).Format("15:04"))
f.SetCellStyle(sheet, cellIn, cellIn, dataStyle) 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) 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.SetCellValue(sheet, cellWork, fmtHHMM(totals.TotalSeconds))
f.SetCellStyle(sheet, cellWork, cellWork, dataStyle) f.SetCellStyle(sheet, cellWork, cellWork, dataStyle)
cellBreak, _ := excelize.CoordinatesToCellName(5, row) cellBreak, _ := excelize.CoordinatesToCellName(6, row)
f.SetCellValue(sheet, cellBreak, fmtHHMM(totals.BreakSeconds)) f.SetCellValue(sheet, cellBreak, fmtHHMM(totals.BreakSeconds))
f.SetCellStyle(sheet, cellBreak, cellBreak, dataStyle) 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.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Total")
f.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), totalStyle) 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.SetCellValue(sheet, fmt.Sprintf("E%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.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("E%d", row), totalStyle) 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() buf, err := f.WriteToBuffer()
if err != nil { if err != nil {
@@ -147,6 +188,31 @@ func GenerateMonthlyReport(store *db.Store, chatID int64, year int, month time.M
return buf.Bytes(), nil 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 { func fmtHHMM(seconds int64) string {
hours := seconds / 3600 hours := seconds / 3600
mins := (seconds % 3600) / 60 mins := (seconds % 3600) / 60

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"time" "time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" 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) h.Bot.Send(edit)
} }
// --- Entry points ---
func (h *Handler) HandleMessage(update tgbotapi.Update) { func (h *Handler) HandleMessage(update tgbotapi.Update) {
msg := update.Message msg := update.Message
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { 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 return
} }
slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) 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) h.handleActionMsg(msg, h.clockIn)
case "/clockout", "Clock Out": case "/clockout", "Clock Out":
h.handleActionMsg(msg, h.clockOut) h.handleActionMsg(msg, h.clockOut)
case "/remote": case "/worktype":
h.handleActionMsg(msg, h.toggleRemote) h.handleWorkTypeMsg(msg)
case "/report": case "/report":
h.handleActionMsg(msg, h.report) h.handleActionMsg(msg, h.report)
case "/export": case "/export":
h.handleExport(msg) h.handleExport(msg)
case "/dayoff": case "/dayoff":
h.handleActionMsg(msg, h.dayOff) h.handleActionMsg(msg, h.dayOff)
case "/reporttoggle":
h.handleActionMsg(msg, h.toggleReport)
case "/setreporttime":
h.handleSetReportTime(msg)
default: default:
h.sendText(msg.Chat.ID, "Unknown command") 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) { func (h *Handler) HandleCallback(update tgbotapi.Update) {
cb := update.CallbackQuery cb := update.CallbackQuery
if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Chat.ID] { 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")) h.Bot.Request(tgbotapi.NewCallback(cb.ID, "Unauthorized"))
return return
} }
slog.Info("callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data) slog.Info("callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data)
chatID := cb.Message.Chat.ID chatID := cb.Message.Chat.ID
msgID := cb.Message.MessageID msgID := cb.Message.MessageID
switch cb.Data { switch cb.Data {
case "clockin": case "clockin":
h.handleActionCallback(chatID, msgID, cb.ID, h.clockIn) h.handleActionCallback(chatID, msgID, cb.ID, h.clockIn)
case "clockout": case "clockout":
h.handleActionCallback(chatID, msgID, cb.ID, h.clockOut) h.handleActionCallback(chatID, msgID, cb.ID, h.clockOut)
case "remote": case "worktype":
h.handleActionCallback(chatID, msgID, cb.ID, h.toggleRemote) h.workTypeCallback(chatID, msgID, cb.ID)
case "report": case "report":
h.handleActionCallback(chatID, msgID, cb.ID, h.report) h.handleActionCallback(chatID, msgID, cb.ID, h.report)
case "export": case "export":
@@ -99,46 +101,67 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) {
h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff)
case "back_menu": case "back_menu":
h.backToMenu(chatID, msgID, cb.ID) 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) { func (h *Handler) clockIn(chatID int64) (string, error) {
today := time.Now().Format("2006-01-02") user, day, err := h.getUserToday(chatID)
isOff, err := h.DB.IsDayOff(chatID, today)
if err != nil { if err != nil {
return "", err return "", err
} }
if isOff { if day.IsDayOff {
return "", errors.New("Today is a day off. Use /dayoff to remove it first.") 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 { if err != nil && err != sql.ErrNoRows {
return "", err return "", err
} }
if last != nil && last.EventType == "in" { if last != nil && last.EventType == "in" {
return "", errors.New("Already clocked in") return "", errors.New("Already clocked in")
} }
if _, err := h.DB.GetSetting(chatID, "remote_flag"); err != nil { loc, _ := time.LoadLocation(user.Timezone)
h.DB.SetSetting(chatID, "remote_flag", "onsite") now := time.Now().In(loc)
} wt := day.CurrentWorkTypeID
if err := h.DB.CreateEvent(chatID, "in", time.Now().Unix(), ""); err != nil { if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil {
return "", err 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) { func (h *Handler) clockOut(chatID int64) (string, error) {
today := time.Now().Format("2006-01-02") user, day, err := h.getUserToday(chatID)
isOff, err := h.DB.IsDayOff(chatID, today)
if err != nil { if err != nil {
return "", err return "", err
} }
if isOff { if day.IsDayOff {
return "", errors.New("Today is a day off. Use /dayoff to remove it first.") 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 { if err != nil {
return "", err return "", err
} }
@@ -148,58 +171,100 @@ func (h *Handler) clockOut(chatID int64) (string, error) {
if last.EventType == "out" { if last.EventType == "out" {
return "", errors.New("Already clocked 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 "", err
} }
return "Clocked out at " + time.Now().Format("15:04"), nil return fmt.Sprintf("Clocked out at %s", 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
} }
func (h *Handler) dayOff(chatID int64) (string, error) { func (h *Handler) dayOff(chatID int64) (string, error) {
today := time.Now().Format("2006-01-02") user, day, err := h.getUserToday(chatID)
isOff, err := h.DB.IsDayOff(chatID, today)
if err != nil { if err != nil {
return "", err return "", err
} }
if isOff { if day.IsDayOff {
if err := h.DB.RemoveDayOff(chatID, today); err != nil { if err := h.DB.RemoveDayOff(user.ID, day.Date); err != nil {
return "", err return "", err
} }
return "Day off removed", nil 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 "", err
} }
return "Today marked as day off", nil return "Today marked as day off", nil
} }
func (h *Handler) report(chatID int64) (string, error) { 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) { func (h *Handler) handleStart(msg *tgbotapi.Message) {
h.DB.SetSetting(0, "report_chat_id", fmt.Sprintf("%d", msg.Chat.ID)) user, err := h.getOrCreateUser(msg.Chat.ID)
slog.Info("start", "chat_id", 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 := tgbotapi.NewMessage(msg.Chat.ID, "We hope you have a happy day working.")
reply.ReplyMarkup = quickKeyboard() reply.ReplyMarkup = quickKeyboard()
h.Bot.Send(reply) h.Bot.Send(reply)
@@ -208,18 +273,98 @@ func (h *Handler) handleStart(msg *tgbotapi.Message) {
h.Bot.Send(menu) 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) { 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() now := time.Now()
slog.Info("export", "chat_id", msg.Chat.ID, "year", now.Year(), "month", now.Month()) slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month())
data, err := GenerateMonthlyReport(h.DB, msg.Chat.ID, now.Year(), now.Month()) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, now.Year(), now.Month())
if err != nil { if err != nil {
slog.Error("generate report", "error", err) slog.Error("generate report", "error", err)
h.sendText(msg.Chat.ID, "Error generating report") h.sendText(msg.Chat.ID, "Error generating report")
return return
} }
slog.Info("report generated", "bytes", len(data))
doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{ doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{
Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")),
Bytes: data, Bytes: data,
@@ -232,9 +377,17 @@ func (h *Handler) handleExport(msg *tgbotapi.Message) {
func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) 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() now := time.Now()
slog.Info("export", "chat_id", chatID, "year", now.Year(), "month", now.Month()) slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month())
data, err := GenerateMonthlyReport(h.DB, chatID, now.Year(), now.Month()) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, now.Year(), now.Month())
if err != nil { if err != nil {
slog.Error("generate report", "error", err) slog.Error("generate report", "error", err)
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") 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) { func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) {
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
edit := tgbotapi.NewEditMessageText(chatID, msgID, "Welcome. Choose an action:") 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) h.Bot.Send(edit)
} }
// --- Daily report builder --- func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string {
loc, err := time.LoadLocation(user.Timezone)
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)
if err != nil { if err != nil {
return "Error fetching events." loc = time.UTC
} }
if len(events) == 0 { if len(events) == 0 {
if isOff { if day.IsDayOff {
return fmt.Sprintf("%s Day Off", dateStr) 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" totals := ComputeDailyTotals(events, day.MinBreakThreshold)
current, err := h.DB.GetSetting(chatID, "remote_flag") lines := fmt.Sprintf("Report for %s", day.Date)
if err == nil && current == "remote" { if day.IsDayOff {
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 {
lines += "\nDay Off: Yes" lines += "\nDay Off: Yes"
} }
lines += "\n\n" lines += "\n\n"
for _, e := range events { 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" label := "IN"
action := "Clock In"
if e.EventType == "out" { if e.EventType == "out" {
label = "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) workStr := formatDuration(totals.TotalSeconds)
@@ -322,17 +460,36 @@ func (h *Handler) buildDailyReport(chatID int64, now time.Time) string {
return lines 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) { day, err := h.DB.GetOrCreateDay(user.ID, today)
text := h.buildDailyReport(chatID, time.Now()) 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 := tgbotapi.NewMessage(chatID, text)
reply.ReplyMarkup = quickKeyboard() reply.ReplyMarkup = quickKeyboard()
h.Bot.Send(reply) h.Bot.Send(reply)
h.DB.MarkReportSent(user.ID, today)
} }
// --- Keyboards ---
func quickKeyboard() tgbotapi.ReplyKeyboardMarkup { func quickKeyboard() tgbotapi.ReplyKeyboardMarkup {
return tgbotapi.NewReplyKeyboard( return tgbotapi.NewReplyKeyboard(
tgbotapi.NewKeyboardButtonRow( tgbotapi.NewKeyboardButtonRow(
@@ -349,12 +506,12 @@ func mainKeyboard() tgbotapi.InlineKeyboardMarkup {
tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"), tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"),
), ),
tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Report", "report"), tgbotapi.NewInlineKeyboardButtonData("Work Type", "worktype"),
tgbotapi.NewInlineKeyboardButtonData("Export", "export"), tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"),
), ),
tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("Toggle Remote/Onsite", "remote"), tgbotapi.NewInlineKeyboardButtonData("Report", "report"),
tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"), tgbotapi.NewInlineKeyboardButtonData("Export", "export"),
), ),
) )
} }
@@ -367,8 +524,6 @@ func backKeyboard() tgbotapi.InlineKeyboardMarkup {
) )
} }
// --- Helpers ---
func formatDuration(seconds int64) string { func formatDuration(seconds int64) string {
hours := seconds / 3600 hours := seconds / 3600
mins := (seconds % 3600) / 60 mins := (seconds % 3600) / 60

View File

@@ -20,7 +20,7 @@ type DailyTotals struct {
PairCount int PairCount int
} }
func ComputeDailyTotals(events []db.Event) DailyTotals { func ComputeDailyTotals(events []db.Event, minBreakThreshold int64) DailyTotals {
if len(events) == 0 { if len(events) == 0 {
return DailyTotals{} return DailyTotals{}
} }
@@ -43,9 +43,13 @@ func ComputeDailyTotals(events []db.Event) DailyTotals {
workStart = e.OccurredAt workStart = e.OccurredAt
state = StateWorking state = StateWorking
case StateOnBreak: case StateOnBreak:
breakTotal += e.OccurredAt - breakStart breakDuration := e.OccurredAt - breakStart
breakStart = 0 if minBreakThreshold > 0 && breakDuration <= minBreakThreshold {
workStart = e.OccurredAt workStart = breakStart
} else {
breakTotal += breakDuration
workStart = e.OccurredAt
}
state = StateWorking state = StateWorking
} }
case "out": case "out":

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"log/slog" "log/slog"
"os" "os"
"time"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -16,6 +17,48 @@ type Store struct {
db *sql.DB 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) { func NewStore(path string) (*Store, error) {
db, err := sql.Open(driverName, path) db, err := sql.Open(driverName, path)
if err != nil { if err != nil {
@@ -37,6 +80,10 @@ func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *Store) DB() *sql.DB {
return s.db
}
func execSchema(db *sql.DB, path string) error { func execSchema(db *sql.DB, path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -46,15 +93,10 @@ func execSchema(db *sql.DB, path string) error {
return err return err
} }
func migrate(db *sql.DB) error { func hasTable(db *sql.DB, name string) bool {
if hasColumn(db, "events", "chat_id") { var exists int
return nil db.QueryRow("SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&exists)
} return exists > 0
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 hasColumn(db *sql.DB, table, column string) bool { func hasColumn(db *sql.DB, table, column string) bool {
@@ -78,22 +120,385 @@ func hasColumn(db *sql.DB, table, column string) bool {
return false 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, &note, &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( _, err := s.db.Exec(
"INSERT INTO events (chat_id, event_type, occurred_at, note) VALUES (?, ?, ?, ?)", "UPDATE users SET timezone=?, is_active=?, report_enabled=?, report_time=?, last_report_date=?, export_accent=?, updated_at=unixepoch() WHERE id=?",
chatID, eventType, occurredAt, note, user.Timezone, user.IsActive, user.ReportEnabled, user.ReportTime, user.LastReportDate, user.ExportAccent, user.ID,
) )
return err 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(` 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 FROM events
WHERE chat_id = ? WHERE user_id = ?
ORDER BY occurred_at DESC ORDER BY occurred_at DESC
LIMIT 1 LIMIT 1
`, chatID) `, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -102,58 +507,24 @@ func (s *Store) GetLastEvent(chatID int64) (*Event, error) {
return nil, sql.ErrNoRows return nil, sql.ErrNoRows
} }
var e Event 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 return nil, err
} }
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
return &e, nil return &e, nil
} }
func (s *Store) GetSetting(chatID int64, key string) (string, error) { func (s *Store) EventsForDayByDate(userID int64, date string) ([]Event, 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) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, chat_id, event_type, occurred_at, note, created_at 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 FROM events e
WHERE chat_id = ? AND strftime('%Y-%m-%d', occurred_at, 'unixepoch') = ? JOIN days d ON d.id = e.day_id
ORDER BY occurred_at ASC WHERE d.user_id = ? AND d.date = ?
`, chatID, day) ORDER BY e.occurred_at ASC
`, userID, date)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -161,19 +532,40 @@ func (s *Store) EventsForDay(chatID int64, day string) ([]Event, error) {
var events []Event var events []Event
for rows.Next() { for rows.Next() {
var e Event 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 return nil, err
} }
if wt.Valid {
e.WorkTypeID = &wt.Int64
}
events = append(events, e) events = append(events, e)
} }
return events, rows.Err() return events, rows.Err()
} }
type Event struct { func (s *Store) EventsForDayByDayID(dayID int64) ([]Event, error) {
ID int64 rows, err := s.db.Query(`
ChatID int64 SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at
EventType string FROM events
OccurredAt int64 WHERE day_id = ?
Note string ORDER BY occurred_at ASC
CreatedAt int64 `, 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()
} }