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